EntryDetailScreen.kt

32.5 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.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    // Null when this entry is the post-unlock landing surface (C1: entry deck93    // is primary, not a child of the vault list) — there's genuinely nothing94    // to go "back" to, so the top chrome shows a lock affordance instead.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                // C1: swipe left/right between deck entries, mirroring the119                // prev/next arrows in EntryDetailSearchStrip. Resolved on120                // drag end (not per-event) so an in-progress vertical scroll121                // gesture on the content below can't accidentally trigger it.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                // D11 — breach banner strip when this entry's password is known-compromised.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                // Keep the title clean, then give the ghost mark its own lower184                // zone so the watermark never sits behind the white entry name.185                Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {186                        EditableTitleHero(187                            title = e.title,188                            onSave = { vm.updateField("title", it) },189                        )190                    Spacer(Modifier.height(PeachSpacing.sm))191                    Box(Modifier.fillMaxWidth().height(100.dp)) {192                        Text(193                            text = ghostWatermarkText(e.title),194                            style = PeachType.ghostMark.copy(fontSize = 150.sp),195                            color = peachColors.T0.copy(alpha = 0.03f),196                            maxLines = 1,197                            softWrap = false,198                            overflow = TextOverflow.Visible,199                            modifier = Modifier200                                .align(Alignment.TopStart)201                                .wrapContentHeight(unbounded = true),202                        )203                    }204                }205                Spacer(Modifier.height(PeachSpacing.sm))206207                if (e.isCard()) {208                    CardDetailFields(209                        entry = e,210                        cardRevealed = cardRevealed,211                        cvcRevealed = cvcRevealed,212                        copiedField = copiedField,213                        onToggleCardReveal = { cardRevealed = !cardRevealed },214                        onRevealCvc = {215                            val activity = context as? FragmentActivity216                            if (activity != null && Biometrics.isAvailable(context)) {217                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)218                            } else vm.revealCvc()219                        },220                        onMaskCvc = vm::maskCvc,221                        onCopy = vm::copy,222                        onSaveCard = vm::updateCard,223                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },224                    )225                } else {226                    val showUsernameField = !e.username.isNullOrBlank()227                    val showEmailField = !e.email.isNullOrBlank()228                    val hasPassword = !e.password.isNullOrBlank()229                    if (!e.isNote()) {230                        CredentialStack {231                            EditableFieldRow(232                                label = "url", field = "url", value = e.url.orEmpty(),233                                copied = copiedField == "url",234                                onCopy = { vm.copy("url", e.url) },235                                onSave = { vm.updateField("url", it) },236                                placeholder = "add url",237                                onEditingChange = { urlEditing = it },238                                stacked = true,239                                divider = showUsernameField || showEmailField || hasPassword,240                            )241                            // Browser distinguishes USERNAME and EMAIL as separate242                            // optional fields, with a "+ add X" link for whichever the243                            // entry doesn't have yet — an entry can carry both.244                            if (showUsernameField) {245                                EditableFieldRow(246                                    label = "username", field = "username", value = e.username.orEmpty(),247                                    copied = copiedField == "username",248                                    onCopy = { vm.copy("username", e.username) },249                                    onSave = { vm.updateField("username", it) },250                                    suggestions = usernameSuggestions,251                                    placeholder = "add username",252                                    stacked = true,253                                    divider = showEmailField || hasPassword,254                                )255                            }256                            if (showEmailField) {257                                EditableFieldRow(258                                    label = "email", field = "email", value = e.email.orEmpty(),259                                    copied = copiedField == "email",260                                    onCopy = { vm.copy("email", e.email) },261                                    onSave = { vm.updateField("email", it) },262                                    suggestions = emailSuggestions,263                                    placeholder = "add email",264                                    stacked = true,265                                    divider = hasPassword,266                                )267                            }268                            e.password?.takeIf { it.isNotBlank() }?.let { pw ->269                                EditablePasswordRow(270                                    value = pw,271                                    copied = copiedField == "password",272                                    entryTitle = e.title,273                                    onCopy = { vm.copy("password", pw) },274                                    onSave = { vm.updateField("password", it) },275                                    onGenerate = { generatorOpen = true },276                                    stacked = true,277                                )278                            }279                        }280                    }281                    AdditionalUrlRows(282                        urls = e.additionalUrls.orEmpty(),283                        copiedField = copiedField,284                        onCopy = { key, value -> vm.copy(key, value) },285                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },286                        onAdd = vm::addAdditionalUrl,287                        showAdd = urlEditing,288                    )289                    if (e.hasPasskey()) {290                        PasskeyDetailSection(e)291                    }292                    if (e.hasTOTP()) {293                        Spacer(Modifier.height(4.dp))294                        TotpBandEditable(295                            totpField = e.totp!!,296                            copied = copiedField == "totp",297                            onCopy = vm::copyTotp,298                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },299                        )300                    }301                }302                e.notes?.takeIf { it.isNotBlank() }?.let { v ->303                    // A note authored in the browser is HTML: shown rendered,304                    // edited as markup, saved back as the same tags.305                    val notesAreHtml = isNoteHtml(v)306                    EditableFieldRow(307                        label = "notes", field = "notes", value = v,308                        copied = copiedField == "notes",309                        onCopy = { vm.copy("notes", notePlainText(v)) },310                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },311                        mono = false, singleLine = false,312                        displayText = noteRendered(v),313                        draftText = noteEditorText(v),314                    )315                }316                EntryDetailActions(317                    confirmingDelete = confirmingDelete,318                    onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },319                    onDelete = {320                        if (confirmingDelete) {321                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })322                        } else {323                            confirmingDelete = true324                        }325                    },326                )327                Spacer(Modifier.height(PeachSpacing.xxl))328            }329330            if (e != null) {331                EntryDetailBottomControls(332                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },333                    totalEntries = activeEntries.size,334                    onOpenList = { onNavigate(PeachNav.VaultList) },335                    onSearch = { onNavigate(PeachNav.VaultSearch) },336                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },337                    onOpenNotes = { onNavigate(PeachNav.Notes) },338                    onSettings = { onNavigate(PeachNav.Settings) },339                    onLock = vm::lock,340                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },341                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },342                    modifier = Modifier.align(Alignment.BottomCenter),343                )344            }345346            if (generatorOpen) {347                GeneratorSheet(348                    copied = copiedField == "genDraft",349                    onCopy = { draft -> vm.copy("genDraft", draft) },350                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },351                    onDismiss = { generatorOpen = false },352                )353            }354        }355    }356}357358@Composable359private fun EntryDetailActions(360    confirmingDelete: Boolean,361    onEdit: () -> Unit,362    onDelete: () -> Unit,363) {364    val accent = LocalPeachAccent.current365    Row(366        Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),367        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),368    ) {369        DetailActionButton(370            label = "edit",371            color = peachColors.Bg,372            background = accent,373            border = accent,374            onClick = onEdit,375            modifier = Modifier.weight(1f),376        )377        DetailActionButton(378            label = if (confirmingDelete) "confirm trash" else "move to trash",379            color = if (confirmingDelete) peachColors.Breach else accent,380            background = Color.Transparent,381            border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,382            onClick = onDelete,383            modifier = Modifier.weight(1f),384        )385    }386}387388@Composable389private fun DetailActionButton(390    label: String,391    color: Color,392    background: Color,393    border: Color,394    onClick: () -> Unit,395    modifier: Modifier = Modifier,396) {397    Box(398        modifier.height(52.dp)399            .clip(RoundedCornerShape(PeachRadius.Button))400            .background(background)401            .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))402            .clickable(onClick = onClick),403        contentAlignment = Alignment.Center,404    ) {405        Text(label, color = color, style = PeachType.bodyMono)406    }407}408409@Composable410private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {411    val accent = LocalPeachAccent.current412    var editing by remember(title) { mutableStateOf(false) }413    var draft by remember(title) { mutableStateOf(title) }414    if (!editing) {415        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {416            CharEnterText(417                text = title,418                style = PeachType.entryNameHero.copy(color = peachColors.T0),419            )420        }421        return422    }423    Column(Modifier.fillMaxWidth()) {424        BasicTextField(425            value = draft,426            onValueChange = { draft = it },427            singleLine = true,428            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),429            cursorBrush = SolidColor(accent),430            modifier = Modifier.fillMaxWidth().drawBehind {431                drawLine(432                    color = accent,433                    start = Offset(0f, size.height),434                    end = Offset(size.width, size.height),435                    strokeWidth = 1.dp.toPx(),436                )437            },438        )439        Spacer(Modifier.height(8.dp))440        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {441            Text(442                "✓ save", color = accent, style = PeachType.labelMono,443                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {444                    onSave(draft)445                    editing = false446                }.padding(vertical = 4.dp),447            )448            Text(449                "✗", color = peachColors.T2, style = PeachType.labelMono,450                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {451                    draft = title452                    editing = false453                }.padding(horizontal = 6.dp, vertical = 4.dp),454            )455        }456    }457}458459@Composable460private fun HomographWarning() {461    Row(462        Modifier.fillMaxWidth()463            .padding(top = 8.dp)464            .clip(RoundedCornerShape(8.dp))465            .background(peachColors.BreachBg)466            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))467            .padding(horizontal = 12.dp, vertical = 9.dp),468        verticalAlignment = Alignment.CenterVertically,469    ) {470        Text("!", color = peachColors.Breach, style = PeachType.labelMono)471        Spacer(Modifier.width(10.dp))472        Text(473            "This URL contains non-ASCII or punycode characters.",474            color = peachColors.Breach,475            style = PeachType.labelMono,476            modifier = Modifier.weight(1f),477        )478    }479}480481@Composable482private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {483    val passkey = entry.passkey ?: return484    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {485        Column(Modifier.weight(1f)) {486            Text(487                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,488                color = peachColors.T0,489                style = PeachType.body,490            )491            Text(492                listOfNotNull(493                    passkey.rpId,494                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,495                ).joinToString(" · "),496                color = peachColors.T2,497                style = PeachType.labelMono,498            )499            Text(500                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",501                color = peachColors.T2,502                style = PeachType.labelMono,503                modifier = Modifier.padding(top = 3.dp),504            )505        }506    }507}508509@Composable510internal fun CanaryEditSection(511    entryId: String,512    modifiedAt: Long,513    canary: com.peachpasswords.vault.Canary?,514    isPro: Boolean,515    message: String?,516    testing: Boolean,517    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,518    onTest: (webhookUrl: String) -> Unit,519) {520    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }521    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }522    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }523    val gated = !isPro && canary == null524    FieldShell("canary alert") {525        Column(Modifier.weight(1f)) {526            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {527                Text(528                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",529                    color = peachColors.T0,530                    style = PeachType.body,531                    modifier = Modifier.weight(1f),532                )533                if (gated) {534                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)535                }536                Text(537                    if (enabled) "on" else "off",538                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,539                    style = PeachType.labelMono,540                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {541                        if (gated) {542                            onSave(true, webhookUrl, label)543                        } else {544                            val next = !enabled545                            enabled = next546                            if (!next) onSave(false, webhookUrl, label)547                        }548                    }.padding(horizontal = 6.dp, vertical = 4.dp),549                )550            }551            Text(552                "Fires a silent webhook when this entry's password or username is copied.",553                color = peachColors.T2,554                style = PeachType.labelMono,555                modifier = Modifier.padding(top = 3.dp),556            )557            if (enabled) {558                Spacer(Modifier.height(8.dp))559                PeachField(560                    label = "webhook url",561                    value = webhookUrl,562                    onChange = { webhookUrl = it },563                )564                Text(565                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",566                    color = peachColors.T2,567                    style = PeachType.labelMono,568                )569                PeachField(570                    label = "label",571                    value = label,572                    onChange = { label = it },573                    mono = false,574                )575                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {576                    Text(577                        "save",578                        color = LocalPeachAccent.current,579                        style = PeachType.labelMono,580                        modifier = Modifier.clip(RoundedCornerShape(6.dp))581                            .clickable { onSave(true, webhookUrl, label) }582                            .padding(vertical = 4.dp),583                    )584                    Text(585                        if (testing) "sending..." else "test ping",586                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,587                        style = PeachType.labelMono,588                        modifier = Modifier.clip(RoundedCornerShape(6.dp))589                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }590                            .padding(vertical = 4.dp),591                    )592                }593            }594            message?.let {595                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))596            }597            if (canary?.lastPinged != null || canary?.pingCount != null) {598                Text(599                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),600                    color = peachColors.T2,601                    style = PeachType.labelMono,602                    modifier = Modifier.padding(top = 3.dp),603                )604            }605        }606    }607}608609@Composable610private fun EntryDetailBottomControls(611    currentIndex: Int,612    totalEntries: Int,613    onOpenList: () -> Unit,614    onSearch: () -> Unit,615    onAddEntry: () -> Unit,616    onOpenNotes: () -> Unit,617    onSettings: () -> Unit,618    onLock: () -> Unit,619    onPrevious: () -> Unit,620    onNext: () -> Unit,621    modifier: Modifier = Modifier,622) {623    val accent = LocalPeachAccent.current624    val hasEntries = totalEntries > 0 && currentIndex >= 0625    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"626627    Column(628        modifier629            .fillMaxWidth()630            .background(631                Brush.verticalGradient(632                    listOf(Color.Transparent, peachColors.Bg.copy(alpha = 0.92f), peachColors.Bg),633                ),634            )635    ) {636        Box(637            Modifier.fillMaxWidth().height(84.dp),638            contentAlignment = Alignment.BottomCenter,639        ) {640            Row(641                Modifier.fillMaxWidth().height(44.dp),642                horizontalArrangement = Arrangement.Center,643                verticalAlignment = Alignment.CenterVertically,644            ) {645                Icon(646                    Icons.AutoMirrored.Rounded.ArrowBack,647                    contentDescription = "Previous entry",648                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,649                    modifier = Modifier650                        .size(36.dp)651                        .clip(RoundedCornerShape(8.dp))652                        .clickable(enabled = hasEntries, onClick = onPrevious)653                        .padding(8.dp),654                )655                Spacer(Modifier.width(PeachSpacing.md))656                Text(counter, color = peachColors.T2, style = PeachType.labelMono)657                Spacer(Modifier.width(PeachSpacing.md))658                Spacer(Modifier.width(4.dp))659                Icon(660                    Icons.AutoMirrored.Rounded.ArrowForward,661                    contentDescription = "Next entry",662                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,663                    modifier = Modifier664                        .size(36.dp)665                        .clip(RoundedCornerShape(8.dp))666                        .clickable(enabled = hasEntries, onClick = onNext)667                        .padding(8.dp),668                )669            }670        }671        Column(672            Modifier.fillMaxWidth()673                .navigationBarsPadding(),674        ) {675            PeachDeckBottomBar(676                onSearch = onSearch,677                onAdd = onAddEntry,678                onNotes = onOpenNotes,679                onSettings = onSettings,680                onLock = onLock,681                onSwipeUp = onOpenList,682            )683        }684    }685}686687/** A credential field card: uppercase mono label over its value and local actions. */688@Composable689internal fun FieldShell(690    label: String,691    modifier: Modifier = Modifier,692    stacked: Boolean = false,693    divider: Boolean = false,694    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,695) {696    if (stacked) {697        Column(modifier.fillMaxWidth()) {698            Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {699                Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))700                Spacer(Modifier.height(6.dp))701                Row(verticalAlignment = Alignment.CenterVertically, content = content)702            }703            if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))704        }705        return706    }707    Column(708        modifier.fillMaxWidth()709            .padding(vertical = PeachSpacing.xs)710            .clip(RoundedCornerShape(PeachRadius.StackCard))711            .background(peachColors.SurfaceCard)712            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))713            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),714    ) {715        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))716        Spacer(Modifier.height(6.dp))717        Row(verticalAlignment = Alignment.CenterVertically, content = content)718    }719}720721@Composable722private fun CredentialStack(content: @Composable () -> Unit) {723    Column(724        Modifier.fillMaxWidth()725            .padding(vertical = PeachSpacing.xs)726            .clip(RoundedCornerShape(PeachRadius.StackCard))727            .background(peachColors.SurfaceCard)728            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),729    ) { content() }730}731732/**733 * Text for the ghost Garamond watermark behind the entry name: the full title,734 * falling back to "P" for a blank title so the hero never renders empty.735 */736internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }737738internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =739    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)740741internal fun shortCredential(credentialId: String): String {742    if (credentialId.length <= 12) return credentialId743    return credentialId.take(6) + "..." + credentialId.takeLast(4)744}745746internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {747    val count = pingCount ?: 0748    val countLabel = "$count total alert" + if (count == 1) "" else "s"749    if (lastPinged == null) return countLabel750    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)751        .format(java.util.Date(lastPinged))752    return "Last triggered: $date · $countLabel"753}