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.layout.wrapContentWidth29import androidx.compose.foundation.rememberScrollState30import androidx.compose.foundation.shape.RoundedCornerShape31import androidx.compose.foundation.text.BasicTextField32import androidx.compose.material.icons.rounded.Check33import androidx.compose.material.icons.rounded.ContentCopy34import androidx.compose.foundation.verticalScroll35import androidx.compose.material.icons.Icons36import androidx.compose.material.icons.automirrored.rounded.ArrowBack37import androidx.compose.material.icons.automirrored.rounded.ArrowForward38import androidx.compose.material3.Icon39import androidx.compose.material3.Text40import androidx.compose.runtime.Composable41import androidx.compose.runtime.getValue42import androidx.compose.runtime.mutableStateOf43import androidx.compose.runtime.produceState44import androidx.compose.runtime.remember45import androidx.compose.runtime.setValue46import androidx.compose.ui.Alignment47import androidx.compose.ui.Modifier48import androidx.compose.ui.draw.clip49import androidx.compose.ui.draw.drawBehind50import androidx.compose.ui.geometry.Offset51import androidx.compose.ui.geometry.Size52import androidx.compose.ui.graphics.Brush53import androidx.compose.ui.graphics.Color54import androidx.compose.ui.graphics.SolidColor55import androidx.compose.ui.graphics.graphicsLayer56import androidx.compose.ui.platform.LocalContext57import androidx.compose.ui.input.pointer.pointerInput58import androidx.compose.ui.text.TextStyle59import androidx.compose.ui.text.font.FontStyle60import androidx.compose.ui.text.style.TextOverflow61import androidx.compose.ui.unit.dp62import androidx.compose.ui.unit.em63import androidx.compose.ui.unit.sp64import androidx.compose.runtime.collectAsState65import androidx.fragment.app.FragmentActivity66import androidx.hilt.navigation.compose.hiltViewModel67import com.peachpasswords.shared.detectHomograph68import com.peachpasswords.ui.PeachNav69import com.peachpasswords.ui.components.CharEnterText70import com.peachpasswords.ui.components.CopyButton71import com.peachpasswords.ui.components.PeachEmptyOrEndState72import com.peachpasswords.ui.components.SecureFlagEffect73import com.peachpasswords.ui.lock.Biometrics74import com.peachpasswords.ui.theme.LocalPeachAccent75import com.peachpasswords.ui.theme.LocalReduceMotion76import com.peachpasswords.ui.theme.PeachSpacing77import com.peachpasswords.ui.theme.PeachRadius78import com.peachpasswords.ui.theme.peachColors79import com.peachpasswords.ui.theme.PeachType80import com.peachpasswords.ui.theme.ShellBackground81import com.peachpasswords.vault.TOTPGenerator82import com.peachpasswords.vault.hasTOTP83import com.peachpasswords.vault.hasPasskey84import com.peachpasswords.vault.isBreach85import com.peachpasswords.vault.isCard86import com.peachpasswords.vault.isNote87import com.peachpasswords.vault.isPasskeyOnly8889@Composable90fun EntryDetailScreen(91 id: String,92 onNavigate: (PeachNav) -> Unit,93 94 95 96 onBack: (() -> Unit)?,97 modifier: Modifier = Modifier,98 vm: EntryDetailViewModel = hiltViewModel(),99) {100 SecureFlagEffect()101102 val entry by vm.entry.collectAsState()103 val activeEntries by vm.activeEntries.collectAsState()104 val usernameSuggestions by vm.usernameSuggestions.collectAsState()105 val emailSuggestions by vm.emailSuggestions.collectAsState()106 val cvcRevealed by vm.cvcRevealed.collectAsState()107 var cardRevealed by remember { mutableStateOf(false) }108 val copiedField by vm.copiedField.collectAsState()109 val context = LocalContext.current110 val accent = LocalPeachAccent.current111 var confirmingDelete by remember { mutableStateOf(false) }112 var generatorOpen by remember { mutableStateOf(false) }113 var urlEditing by remember { mutableStateOf(false) }114115 val e = entry116 ShellBackground(modifier) {117 Box(118 Modifier.fillMaxSize().statusBarsPadding()119 120 121 122 123 .pointerInput(vm) {124 var dragAccum = 0f125 detectHorizontalDragGestures(126 onDragStart = { dragAccum = 0f },127 onDragEnd = {128 when {129 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)130 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)131 }132 dragAccum = 0f133 },134 onDragCancel = { dragAccum = 0f },135 ) { _, dragAmount -> dragAccum += dragAmount }136 },137 ) {138 Column(139 Modifier.fillMaxSize()140 .verticalScroll(rememberScrollState())141 .padding(horizontal = PeachSpacing.xl)142 .padding(bottom = 172.dp),143 ) {144 PeachVaultIdentity("vault") { onNavigate(PeachNav.VaultList) }145 Spacer(Modifier.height(PeachSpacing.sm))146147 if (e == null) {148 PeachEmptyOrEndState(149 message = "entry not found",150 detail = "It may have been moved or deleted.",151 modifier = Modifier.padding(top = PeachSpacing.xxl),152 )153 return@Column154 }155156 157 if (e.isBreach()) {158 Row(159 Modifier.fillMaxWidth()160 .background(peachColors.BreachBg)161 .padding(horizontal = 16.dp, vertical = 8.dp),162 verticalAlignment = Alignment.CenterVertically,163 ) {164 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)165 Spacer(Modifier.width(10.dp))166 Text(167 "Password found in a data breach — change it now",168 color = peachColors.Breach, style = PeachType.labelMono,169 modifier = Modifier.weight(1f),170 )171 Text(172 "Change password",173 color = accent, style = PeachType.labelMono,174 modifier = Modifier.clip(RoundedCornerShape(6.dp))175 .clickable { generatorOpen = true }176 .padding(horizontal = 6.dp, vertical = 4.dp),177 )178 }179 }180 if (entryHasHomographWarning(e)) {181 HomographWarning()182 }183184 185 186 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {187 EditableTitleHero(188 title = e.title,189 onSave = { vm.updateField("title", it) },190 )191 Spacer(Modifier.height(PeachSpacing.sm))192 Box(Modifier.fillMaxWidth().height(100.dp)) {193 Text(194 text = ghostWatermarkText(e.title),195 style = PeachType.ghostMark.copy(fontSize = 150.sp),196 color = peachColors.T0.copy(alpha = 0.03f),197 maxLines = 1,198 softWrap = false,199 overflow = TextOverflow.Visible,200 modifier = Modifier201 .align(Alignment.TopStart)202 .wrapContentWidth(unbounded = true)203 .wrapContentHeight(unbounded = true),204 )205 }206 }207 Spacer(Modifier.height(PeachSpacing.sm))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}