package com.peachpasswords.ui.vault import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.runtime.collectAsState import androidx.fragment.app.FragmentActivity import androidx.hilt.navigation.compose.hiltViewModel import com.peachpasswords.shared.detectHomograph import com.peachpasswords.ui.PeachNav import com.peachpasswords.ui.components.CharEnterText import com.peachpasswords.ui.components.CopyButton import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.SecureFlagEffect import com.peachpasswords.ui.components.TOTPRing import com.peachpasswords.ui.lock.Biometrics import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.LocalReduceMotion import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachRadius import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.hasTOTP import com.peachpasswords.vault.hasPasskey import com.peachpasswords.vault.isBreach import com.peachpasswords.vault.isCard import com.peachpasswords.vault.isNote import com.peachpasswords.vault.isPasskeyOnly @Composable fun EntryDetailScreen( id: String, onNavigate: (PeachNav) -> Unit, // Null when this entry is the post-unlock landing surface (C1: entry deck // is primary, not a child of the vault list) — there's genuinely nothing // to go "back" to, so the top chrome shows a lock affordance instead. onBack: (() -> Unit)?, modifier: Modifier = Modifier, vm: EntryDetailViewModel = hiltViewModel(), ) { SecureFlagEffect() val entry by vm.entry.collectAsState() val activeEntries by vm.activeEntries.collectAsState() val identitySuggestions by vm.identitySuggestions.collectAsState() val allTags by vm.allTags.collectAsState() val isPro by vm.isPro.collectAsState() val cvcRevealed by vm.cvcRevealed.collectAsState() var cardRevealed by remember { mutableStateOf(false) } val copiedField by vm.copiedField.collectAsState() val canaryMessage by vm.canaryMessage.collectAsState() val canaryTesting by vm.canaryTesting.collectAsState() val context = LocalContext.current val accent = LocalPeachAccent.current var confirmingDelete by remember { mutableStateOf(false) } var generatorOpen by remember { mutableStateOf(false) } var historyExpanded by remember { mutableStateOf(false) } var urlEditing by remember { mutableStateOf(false) } val e = entry ShellBackground(modifier) { Box( Modifier.fillMaxSize().statusBarsPadding() // C1: swipe left/right between deck entries, mirroring the // prev/next arrows in EntryDetailSearchStrip. Resolved on // drag end (not per-event) so an in-progress vertical scroll // gesture on the content below can't accidentally trigger it. .pointerInput(vm) { var dragAccum = 0f detectHorizontalDragGestures( onDragStart = { dragAccum = 0f }, onDragEnd = { when { dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate) dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate) } dragAccum = 0f }, onDragCancel = { dragAccum = 0f }, ) { _, dragAmount -> dragAccum += dragAmount } }, ) { Column( Modifier.fillMaxSize() .padding(bottom = 172.dp) .verticalScroll(rememberScrollState()) .padding(horizontal = PeachSpacing.xl), ) { PeachVaultIdentity("vault") { onNavigate(PeachNav.VaultList) } Spacer(Modifier.height(PeachSpacing.xxl)) if (e == null) { PeachEmptyOrEndState( message = "entry not found", detail = "It may have been moved or deleted.", modifier = Modifier.padding(top = PeachSpacing.xxl), ) return@Column } // D11 — breach banner strip when this entry's password is known-compromised. if (e.isBreach()) { Row( Modifier.fillMaxWidth() .background(peachColors.BreachBg) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono) Spacer(Modifier.width(10.dp)) Text( "Password found in a data breach — change it now", color = peachColors.Breach, style = PeachType.labelMono, modifier = Modifier.weight(1f), ) Text( "Change password", color = accent, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)) .clickable { generatorOpen = true } .padding(horizontal = 6.dp, vertical = 4.dp), ) } } if (entryHasHomographWarning(e)) { HomographWarning() } // Hero — ghost PeachScript watermark behind the entry name. The full // title is drawn (not just its first letter); a long title overflows // and clips naturally, which is the intended watermark behavior. Box(Modifier.fillMaxWidth().clipToBounds()) { Text( text = ghostWatermarkText(e.title), style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic), color = peachColors.T0.copy(alpha = 0.03f), maxLines = 1, softWrap = false, overflow = TextOverflow.Clip, modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp), ) Column(Modifier.fillMaxWidth().padding(top = 18.dp)) { EditableTitleHero( title = e.title, onSave = { vm.updateField("title", it) }, ) } } Spacer(Modifier.height(PeachSpacing.lg)) if (e.isCard()) { CardDetailFields( entry = e, cardRevealed = cardRevealed, cvcRevealed = cvcRevealed, copiedField = copiedField, onToggleCardReveal = { cardRevealed = !cardRevealed }, onRevealCvc = { val activity = context as? FragmentActivity if (activity != null && Biometrics.isAvailable(context)) { Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc) } else vm.revealCvc() }, onMaskCvc = vm::maskCvc, onCopy = vm::copy, onSaveCard = vm::updateCard, copyButton = { copied, onCopy -> CopyButton(copied, onCopy) }, ) } else { if (!e.isNote()) { EditableFieldRow( label = "url", field = "url", value = e.url.orEmpty(), copied = copiedField == "url", onCopy = { vm.copy("url", e.url) }, onSave = { vm.updateField("url", it) }, placeholder = "add url", onEditingChange = { urlEditing = it }, ) } // Browser distinguishes USERNAME and EMAIL as separate // optional fields, with a "+ add X" link for whichever the // entry doesn't have yet — an entry can carry both. This // used to just omit the row entirely for a blank field, // with no way to add it from the detail screen. var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) } var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) } if (showUsernameField) { EditableFieldRow( label = "username", field = "username", value = e.username.orEmpty(), copied = copiedField == "username", onCopy = { vm.copy("username", e.username) }, onSave = { vm.updateField("username", it) }, suggestions = identitySuggestions, placeholder = "add username", ) } if (showEmailField) { EditableFieldRow( label = "email", field = "email", value = e.email.orEmpty(), copied = copiedField == "email", onCopy = { vm.copy("email", e.email) }, onSave = { vm.updateField("email", it) }, suggestions = identitySuggestions, placeholder = "add email", ) } if (!showUsernameField || !showEmailField) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { if (!showUsernameField) { Text( "+ add username", color = accent, style = PeachType.labelMono, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable { showUsernameField = true } .padding(vertical = 6.dp), ) } if (!showEmailField) { Text( "+ add email", color = accent, style = PeachType.labelMono, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable { showEmailField = true } .padding(vertical = 6.dp), ) } } } AdditionalUrlRows( urls = e.additionalUrls.orEmpty(), copiedField = copiedField, onCopy = { key, value -> vm.copy(key, value) }, onSave = { index, value -> vm.updateAdditionalUrl(index, value) }, onAdd = vm::addAdditionalUrl, showAdd = urlEditing, ) if (e.hasPasskey()) { PasskeyDetailSection(e) } e.password?.takeIf { it.isNotBlank() }?.let { pw -> EditablePasswordRow( value = pw, copied = copiedField == "password", entryTitle = e.title, onCopy = { vm.copy("password", pw) }, onSave = { vm.updateField("password", it) }, onGenerate = { generatorOpen = true }, ) } // Always show the history affordance (browser parity) — visible // even before any password change, expanding to an empty-state // explainer so the feature is discoverable rather than hidden // until history already exists. val history = e.passwordHistory.orEmpty() PasswordHistoryToggle( count = history.size, expanded = historyExpanded, onToggle = { historyExpanded = !historyExpanded }, ) AnimatedVisibility(visible = historyExpanded) { Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) { PasswordHistoryList( history = history, onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) }, onCopy = { oldPw -> vm.copy("password", oldPw) }, ) } } if (e.hasTOTP()) { Spacer(Modifier.height(4.dp)) TotpBandEditable( totpField = e.totp!!, copied = copiedField == "totp", onCopy = vm::copyTotp, onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) }, ) } } e.notes?.takeIf { it.isNotBlank() }?.let { v -> // A note authored in the browser is HTML: shown rendered, // edited as markup, saved back as the same tags. val notesAreHtml = isNoteHtml(v) EditableFieldRow( label = "notes", field = "notes", value = v, copied = copiedField == "notes", onCopy = { vm.copy("notes", notePlainText(v)) }, onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) }, mono = false, singleLine = false, displayText = noteRendered(v), draftText = noteEditorText(v), ) } EntryDetailActions( confirmingDelete = confirmingDelete, onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) }, onDelete = { if (confirmingDelete) { vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) }) } else { confirmingDelete = true } }, ) Spacer(Modifier.height(PeachSpacing.md)) DetailTagRow( tags = e.tags.orEmpty(), allTags = allTags, onAddTag = vm::addTag, onRemoveTag = vm::removeTag, ) if (!e.isNote() && !e.isCard()) { // Collapsed by default (mirrors the browser's "+ ADVANCED"), // unless a canary is already configured for this entry — every // entry showing an "Off" canary block permanently was clutter // most people never touch. var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) } if (advancedExpanded) { CanaryDetailSection( entryId = e.id, modifiedAt = e.modifiedAt, canary = e.canary, isPro = isPro, message = canaryMessage, testing = canaryTesting, onSave = vm::saveCanary, onTest = vm::testCanary, ) } else { FieldShell("advanced") { Text( "Canary alert and protection", color = peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.weight(1f), ) Text( "open", color = LocalPeachAccent.current, style = PeachType.labelMono, modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp), ) } } } Spacer(Modifier.height(PeachSpacing.xxl)) } if (e != null) { EntryDetailBottomControls( currentIndex = activeEntries.indexOfFirst { it.id == e.id }, totalEntries = activeEntries.size, onOpenList = { onNavigate(PeachNav.VaultList) }, onSearch = { onNavigate(PeachNav.VaultSearch) }, onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) }, onOpenNotes = { onNavigate(PeachNav.Notes) }, onSettings = { onNavigate(PeachNav.Settings) }, onLock = vm::lock, onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) }, onNext = { vm.adjacentDestination(1)?.let(onNavigate) }, modifier = Modifier.align(Alignment.BottomCenter), ) } if (generatorOpen) { GeneratorSheet( copied = copiedField == "genDraft", onCopy = { draft -> vm.copy("genDraft", draft) }, onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } }, onDismiss = { generatorOpen = false }, ) } } } } @Composable private fun EntryDetailActions( confirmingDelete: Boolean, onEdit: () -> Unit, onDelete: () -> Unit, ) { val accent = LocalPeachAccent.current Row( Modifier.fillMaxWidth().padding(top = PeachSpacing.lg), horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm), ) { DetailActionButton( label = "edit", color = peachColors.Bg, background = accent, border = accent, onClick = onEdit, modifier = Modifier.weight(1f), ) DetailActionButton( label = if (confirmingDelete) "confirm trash" else "move to trash", color = if (confirmingDelete) peachColors.Breach else accent, background = Color.Transparent, border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3, onClick = onDelete, modifier = Modifier.weight(1f), ) } } @Composable private fun DetailActionButton( label: String, color: Color, background: Color, border: Color, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier.height(52.dp) .clip(RoundedCornerShape(PeachRadius.Button)) .background(background) .border(1.dp, border, RoundedCornerShape(PeachRadius.Button)) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Text(label, color = color, style = PeachType.bodyMono) } } @Composable private fun EditableTitleHero(title: String, onSave: (String) -> Unit) { val accent = LocalPeachAccent.current var editing by remember(title) { mutableStateOf(false) } var draft by remember(title) { mutableStateOf(title) } if (!editing) { Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) { CharEnterText( text = title, style = PeachType.entryNameHero.copy(color = peachColors.T0), ) } return } Column(Modifier.fillMaxWidth()) { BasicTextField( value = draft, onValueChange = { draft = it }, singleLine = true, textStyle = PeachType.entryNameHero.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), modifier = Modifier.fillMaxWidth().drawBehind { drawLine( color = accent, start = Offset(0f, size.height), end = Offset(size.width, size.height), strokeWidth = 1.dp.toPx(), ) }, ) Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { Text( "✓ save", color = accent, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { onSave(draft) editing = false }.padding(vertical = 4.dp), ) Text( "✗", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { draft = title editing = false }.padding(horizontal = 6.dp, vertical = 4.dp), ) } } } @Composable private fun HomographWarning() { Row( Modifier.fillMaxWidth() .padding(top = 8.dp) .clip(RoundedCornerShape(8.dp)) .background(peachColors.BreachBg) .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { Text("!", color = peachColors.Breach, style = PeachType.labelMono) Spacer(Modifier.width(10.dp)) Text( "This URL contains non-ASCII or punycode characters.", color = peachColors.Breach, style = PeachType.labelMono, modifier = Modifier.weight(1f), ) } } @Composable private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) { val passkey = entry.passkey ?: return FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") { Column(Modifier.weight(1f)) { Text( passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId, color = peachColors.T0, style = PeachType.body, ) Text( listOfNotNull( passkey.rpId, passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName, ).joinToString(" · "), color = peachColors.T2, style = PeachType.labelMono, ) Text( "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 3.dp), ) } } } @Composable private fun CanaryDetailSection( entryId: String, modifiedAt: Long, canary: com.peachpasswords.vault.Canary?, isPro: Boolean, message: String?, testing: Boolean, onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit, onTest: (webhookUrl: String) -> Unit, ) { var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) } var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) } var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) } val gated = !isPro && canary == null FieldShell("canary alert") { Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Text( label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off", color = peachColors.T0, style = PeachType.body, modifier = Modifier.weight(1f), ) if (gated) { Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono) } Text( if (enabled) "on" else "off", color = if (enabled) LocalPeachAccent.current else peachColors.T2, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { if (gated) { onSave(true, webhookUrl, label) } else { val next = !enabled enabled = next if (!next) onSave(false, webhookUrl, label) } }.padding(horizontal = 6.dp, vertical = 4.dp), ) } Text( "Fires a silent webhook when this entry's password or username is copied.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 3.dp), ) if (enabled) { Spacer(Modifier.height(8.dp)) PeachField( label = "webhook url", value = webhookUrl, onChange = { webhookUrl = it }, ) Text( "When triggered, the entry title is sent to this URL. Use an endpoint you control.", color = peachColors.T2, style = PeachType.labelMono, ) PeachField( label = "label", value = label, onChange = { label = it }, mono = false, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { Text( "save", color = LocalPeachAccent.current, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)) .clickable { onSave(true, webhookUrl, label) } .padding(vertical = 4.dp), ) Text( if (testing) "sending..." else "test ping", color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)) .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) } .padding(vertical = 4.dp), ) } } message?.let { Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp)) } if (canary?.lastPinged != null || canary?.pingCount != null) { Text( canaryStatusLabel(canary?.lastPinged, canary?.pingCount), color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 3.dp), ) } } } } @Composable private fun EntryDetailBottomControls( currentIndex: Int, totalEntries: Int, onOpenList: () -> Unit, onSearch: () -> Unit, onAddEntry: () -> Unit, onOpenNotes: () -> Unit, onSettings: () -> Unit, onLock: () -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, modifier: Modifier = Modifier, ) { val accent = LocalPeachAccent.current val hasEntries = totalEntries > 0 && currentIndex >= 0 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries" Column( modifier .fillMaxWidth() ) { // Gentle fade: scrolling content dissolves into the background // instead of hitting a hard opaque edge. Box( Modifier.fillMaxWidth().height(40.dp) .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))), ) Column( Modifier.fillMaxWidth() .background(peachColors.Bg) .navigationBarsPadding(), ) { Row( Modifier.fillMaxWidth().height(44.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Previous entry", tint = if (hasEntries) peachColors.T0 else peachColors.T2, modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(8.dp)) .clickable(enabled = hasEntries, onClick = onPrevious) .padding(8.dp), ) Spacer(Modifier.width(PeachSpacing.md)) Text(counter, color = peachColors.T2, style = PeachType.labelMono) Spacer(Modifier.width(PeachSpacing.md)) Spacer(Modifier.width(4.dp)) Icon( Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = "Next entry", tint = if (hasEntries) peachColors.T0 else peachColors.T2, modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(8.dp)) .clickable(enabled = hasEntries, onClick = onNext) .padding(8.dp), ) } PeachDeckBottomBar( onSearch = onSearch, onAdd = onAddEntry, onNotes = onOpenNotes, onSettings = onSettings, onLock = onLock, onSwipeUp = onOpenList, ) } } } /** A credential field card: uppercase mono label over its value and local actions. */ @Composable internal fun FieldShell( label: String, modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit, ) { Column( modifier.fillMaxWidth() .padding(vertical = PeachSpacing.xs) .clip(RoundedCornerShape(PeachRadius.StackCard)) .background(peachColors.SurfaceCard) .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)) .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em)) Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically, content = content) } } /** * Text for the ghost PeachScript watermark behind the entry name: the full title, * falling back to "P" for a blank title so the hero never renders empty. */ internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" } internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean = (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph) internal fun shortCredential(credentialId: String): String { if (credentialId.length <= 12) return credentialId return credentialId.take(6) + "..." + credentialId.takeLast(4) } internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String { val count = pingCount ?: 0 val countLabel = "$count total alert" + if (count == 1) "" else "s" if (lastPinged == null) return countLabel val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM) .format(java.util.Date(lastPinged)) return "Last triggered: $date · $countLabel" }