EntryDetailScreen.kt

32.7 KB · raw

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    // Null when this entry is the post-unlock landing surface (C1: entry deck94    // is primary, not a child of the vault list) — there's genuinely nothing95    // to go "back" to, so the top chrome shows a lock affordance instead.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                // C1: swipe left/right between deck entries, mirroring the120                // prev/next arrows in EntryDetailSearchStrip. Resolved on121                // drag end (not per-event) so an in-progress vertical scroll122                // gesture on the content below can't accidentally trigger it.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                // D11 — breach banner strip when this entry's password is known-compromised.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                // Keep the title clean, then give the ghost mark its own lower185                // zone so the watermark never sits behind the white entry name.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                            // Browser distinguishes USERNAME and EMAIL as separate244                            // optional fields, with a "+ add X" link for whichever the245                            // entry doesn't have yet — an entry can carry both.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                    // A note authored in the browser is HTML: shown rendered,306                    // edited as markup, saved back as the same tags.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}688689/** A credential field card: uppercase mono label over its value and local actions. */690@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}733734/**735 * Text for the ghost Garamond watermark behind the entry name: the full title,736 * falling back to "P" for a blank title so the hero never renders empty.737 */738internal 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}