EntryDetailScreen.kt

32.1 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectVerticalDragGestures15import androidx.compose.foundation.layout.Arrangement16import androidx.compose.foundation.layout.Box17import androidx.compose.foundation.layout.Column18import androidx.compose.foundation.layout.Row19import androidx.compose.foundation.layout.Spacer20import androidx.compose.foundation.layout.fillMaxSize21import androidx.compose.foundation.layout.fillMaxHeight22import androidx.compose.foundation.layout.fillMaxWidth23import androidx.compose.foundation.layout.height24import androidx.compose.foundation.layout.offset25import androidx.compose.foundation.layout.padding26import androidx.compose.foundation.layout.navigationBarsPadding27import androidx.compose.foundation.layout.size28import androidx.compose.foundation.layout.statusBarsPadding29import androidx.compose.foundation.layout.width30import androidx.compose.foundation.rememberScrollState31import androidx.compose.foundation.shape.CircleShape32import androidx.compose.foundation.shape.RoundedCornerShape33import androidx.compose.foundation.text.BasicTextField34import androidx.compose.material.icons.rounded.Check35import androidx.compose.material.icons.rounded.ContentCopy36import androidx.compose.material.icons.rounded.Search37import androidx.compose.foundation.verticalScroll38import androidx.compose.material.icons.Icons39import androidx.compose.material.icons.automirrored.rounded.ArrowBack40import androidx.compose.material.icons.automirrored.rounded.ArrowForward41import androidx.compose.material.icons.outlined.DeleteOutline42import androidx.compose.material3.Icon43import androidx.compose.material3.Text44import androidx.compose.runtime.Composable45import androidx.compose.runtime.getValue46import androidx.compose.runtime.mutableStateOf47import androidx.compose.runtime.produceState48import androidx.compose.runtime.remember49import androidx.compose.runtime.setValue50import androidx.compose.ui.Alignment51import androidx.compose.ui.Modifier52import androidx.compose.ui.draw.clip53import androidx.compose.ui.draw.clipToBounds54import androidx.compose.ui.draw.drawBehind55import androidx.compose.ui.geometry.Offset56import androidx.compose.ui.geometry.Size57import androidx.compose.ui.graphics.Brush58import androidx.compose.ui.graphics.Color59import androidx.compose.ui.graphics.SolidColor60import androidx.compose.ui.graphics.graphicsLayer61import androidx.compose.ui.platform.LocalContext62import androidx.compose.ui.input.pointer.pointerInput63import androidx.compose.ui.text.TextStyle64import androidx.compose.ui.text.font.FontStyle65import androidx.compose.ui.text.style.TextOverflow66import androidx.compose.ui.unit.dp67import androidx.compose.ui.unit.em68import androidx.compose.ui.unit.sp69import androidx.compose.runtime.collectAsState70import androidx.fragment.app.FragmentActivity71import androidx.hilt.navigation.compose.hiltViewModel72import com.peachpasswords.shared.detectHomograph73import com.peachpasswords.ui.PeachNav74import com.peachpasswords.ui.components.CharEnterText75import com.peachpasswords.ui.components.CopyButton76import com.peachpasswords.ui.components.FaviconInitials77import com.peachpasswords.ui.components.PeachEmptyOrEndState78import com.peachpasswords.ui.components.PeachTopBar79import com.peachpasswords.ui.components.SecureFlagEffect80import com.peachpasswords.ui.components.TOTPRing81import com.peachpasswords.ui.lock.Biometrics82import com.peachpasswords.ui.theme.LocalPeachAccent83import com.peachpasswords.ui.theme.LocalReduceMotion84import com.peachpasswords.ui.theme.PeachSpacing85import com.peachpasswords.ui.theme.peachColors86import com.peachpasswords.ui.theme.PeachType87import com.peachpasswords.ui.theme.ShellBackground88import com.peachpasswords.vault.TOTPGenerator89import com.peachpasswords.vault.hasTOTP90import com.peachpasswords.vault.hasPasskey91import com.peachpasswords.vault.isBreach92import com.peachpasswords.vault.isCard93import com.peachpasswords.vault.isNote94import com.peachpasswords.vault.isPasskeyOnly9596@Composable97fun EntryDetailScreen(98    id: String,99    onNavigate: (PeachNav) -> Unit,100    onBack: () -> Unit,101    vm: EntryDetailViewModel = hiltViewModel(),102) {103    val entry by vm.entry.collectAsState()104    val activeEntries by vm.activeEntries.collectAsState()105    val identitySuggestions by vm.identitySuggestions.collectAsState()106    val allTags by vm.allTags.collectAsState()107    val isPro by vm.isPro.collectAsState()108    val cvcRevealed by vm.cvcRevealed.collectAsState()109    var cardRevealed by remember { mutableStateOf(false) }110    val copiedField by vm.copiedField.collectAsState()111    val canaryMessage by vm.canaryMessage.collectAsState()112    val canaryTesting by vm.canaryTesting.collectAsState()113    val context = LocalContext.current114    val accent = LocalPeachAccent.current115    var confirmingDelete by remember { mutableStateOf(false) }116    var generatorOpen by remember { mutableStateOf(false) }117    var historyExpanded by remember { mutableStateOf(false) }118119    SecureFlagEffect()120121    val e = entry122    ShellBackground {123        Box(Modifier.fillMaxSize().statusBarsPadding()) {124            Column(125                Modifier.fillMaxSize()126                    .padding(bottom = 100.dp)127                    .verticalScroll(rememberScrollState())128                    .padding(horizontal = PeachSpacing.xl),129            ) {130                EntryDetailTopBar(131                    entry = e,132                    confirmingDelete = confirmingDelete,133                    onBack = onBack,134                    onDelete = {135                        if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true136                    },137                )138                Spacer(Modifier.height(PeachSpacing.lg))139140                if (e == null) {141                    PeachEmptyOrEndState(142                        message = "entry not found",143                        detail = "It may have been moved or deleted.",144                        modifier = Modifier.padding(top = PeachSpacing.xxl),145                    )146                    return@Column147                }148149                // D11 — breach banner strip when this entry's password is known-compromised.150                if (e.isBreach()) {151                    Row(152                        Modifier.fillMaxWidth()153                            .background(peachColors.BreachBg)154                            .padding(horizontal = 16.dp, vertical = 8.dp),155                        verticalAlignment = Alignment.CenterVertically,156                    ) {157                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)158                        Spacer(Modifier.width(10.dp))159                        Text(160                            "Password found in a data breach — change it now",161                            color = peachColors.Breach, style = PeachType.labelMono,162                            modifier = Modifier.weight(1f),163                        )164                        Text(165                            "Change password",166                            color = accent, style = PeachType.labelMono,167                            modifier = Modifier.clip(RoundedCornerShape(6.dp))168                                .clickable { generatorOpen = true }169                                .padding(horizontal = 6.dp, vertical = 4.dp),170                        )171                    }172                }173                if (entryHasHomographWarning(e)) {174                    HomographWarning()175                }176177                // Hero — ghost PeachScript watermark behind the entry name. The full178                // title is drawn (not just its first letter); a long title overflows179                // and clips naturally, which is the intended watermark behavior.180                Box(Modifier.fillMaxWidth().clipToBounds()) {181                    Text(182                        text = ghostWatermarkText(e.title),183                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),184                        color = peachColors.T0.copy(alpha = 0.03f),185                        maxLines = 1,186                        softWrap = false,187                        overflow = TextOverflow.Clip,188                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),189                    )190                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {191                        EditableTitleHero(192                            title = e.title,193                            onSave = { vm.updateField("title", it) },194                        )195                    }196                }197                Spacer(Modifier.height(PeachSpacing.lg))198199                if (e.isCard()) {200                    CardDetailFields(201                        entry = e,202                        cardRevealed = cardRevealed,203                        cvcRevealed = cvcRevealed,204                        copiedField = copiedField,205                        onToggleCardReveal = { cardRevealed = !cardRevealed },206                        onRevealCvc = {207                            val activity = context as? FragmentActivity208                            if (activity != null && Biometrics.isAvailable(context)) {209                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)210                            } else vm.revealCvc()211                        },212                        onMaskCvc = vm::maskCvc,213                        onCopy = vm::copy,214                        onSaveCard = vm::updateCard,215                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },216                    )217                } else {218                    if (!e.isNote()) {219                        EditableFieldRow(220                            label = "url", field = "url", value = e.url.orEmpty(),221                            copied = copiedField == "url",222                            onCopy = { vm.copy("url", e.url) },223                            onSave = { vm.updateField("url", it) },224                            placeholder = "add url",225                        )226                    }227                    e.username?.takeIf { it.isNotBlank() }?.let { v ->228                        EditableFieldRow(229                            label = "username", field = "username", value = v,230                            copied = copiedField == "username",231                            onCopy = { vm.copy("username", v) },232                            onSave = { vm.updateField("username", it) },233                            suggestions = identitySuggestions,234                        )235                    }236                    e.email?.takeIf { it.isNotBlank() }?.let { v ->237                        EditableFieldRow(238                            label = "email", field = "email", value = v,239                            copied = copiedField == "email",240                            onCopy = { vm.copy("email", v) },241                            onSave = { vm.updateField("email", it) },242                            suggestions = identitySuggestions,243                        )244                    }245                    AdditionalUrlRows(246                        urls = e.additionalUrls.orEmpty(),247                        copiedField = copiedField,248                        onCopy = { key, value -> vm.copy(key, value) },249                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },250                        onAdd = vm::addAdditionalUrl,251                    )252                    if (e.hasPasskey()) {253                        PasskeyDetailSection(e)254                    }255                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->256                        EditablePasswordRow(257                            value = pw,258                            copied = copiedField == "password",259                            entryTitle = e.title,260                            onCopy = { vm.copy("password", pw) },261                            onSave = { vm.updateField("password", it) },262                            onGenerate = { generatorOpen = true },263                        )264                    }265                    val history = e.passwordHistory.orEmpty()266                    if (history.isNotEmpty()) {267                        PasswordHistoryToggle(268                            count = history.size,269                            expanded = historyExpanded,270                            onToggle = { historyExpanded = !historyExpanded },271                        )272                        AnimatedVisibility(visible = historyExpanded) {273                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {274                                PasswordHistoryList(275                                    history = history,276                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },277                                    onCopy = { oldPw -> vm.copy("password", oldPw) },278                                )279                            }280                        }281                    }282                    if (e.hasTOTP()) {283                        Spacer(Modifier.height(4.dp))284                        TotpBandEditable(285                            totpField = e.totp!!,286                            copied = copiedField == "totp",287                            onCopy = vm::copyTotp,288                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },289                        )290                    }291                }292                e.notes?.takeIf { it.isNotBlank() }?.let { v ->293                    EditableFieldRow(294                        label = "notes", field = "notes", value = v,295                        copied = copiedField == "notes",296                        onCopy = { vm.copy("notes", v) },297                        onSave = { vm.updateField("notes", it) },298                        mono = false, singleLine = false,299                    )300                }301                DetailTagRow(302                    tags = e.tags.orEmpty(),303                    allTags = allTags,304                    onAddTag = vm::addTag,305                    onRemoveTag = vm::removeTag,306                )307                if (!e.isNote() && !e.isCard()) {308                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),309                    // unless a canary is already configured for this entry — every310                    // entry showing an "Off" canary block permanently was clutter311                    // most people never touch.312                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }313                    if (advancedExpanded) {314                        CanaryDetailSection(315                            entryId = e.id,316                            modifiedAt = e.modifiedAt,317                            canary = e.canary,318                            isPro = isPro,319                            message = canaryMessage,320                            testing = canaryTesting,321                            onSave = vm::saveCanary,322                            onTest = vm::testCanary,323                        )324                    } else {325                        Text(326                            "+ advanced",327                            color = LocalPeachAccent.current,328                            style = PeachType.labelMono,329                            modifier = Modifier330                                .clip(RoundedCornerShape(6.dp))331                                .clickable { advancedExpanded = true }332                                .padding(vertical = 6.dp),333                        )334                    }335                }336337                PeachEmptyOrEndState(338                    message = "end of entry",339                    detail = "All saved fields for this item are above.",340                    modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),341                )342            }343344            if (e != null) {345                EntryDetailSearchStrip(346                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },347                    totalEntries = activeEntries.size,348                    onOpenList = { onNavigate(PeachNav.VaultList) },349                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },350                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },351                    modifier = Modifier.align(Alignment.BottomCenter),352                )353            }354355            if (generatorOpen) {356                GeneratorSheet(357                    copied = copiedField == "genDraft",358                    onCopy = { draft -> vm.copy("genDraft", draft) },359                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },360                    onDismiss = { generatorOpen = false },361                )362            }363        }364    }365}366367@Composable368private fun EntryDetailTopBar(369    entry: com.peachpasswords.vault.VaultEntry?,370    confirmingDelete: Boolean,371    onBack: () -> Unit,372    onDelete: () -> Unit,373) {374    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }375    PeachTopBar(376        title = when {377            entry == null -> "entry"378            confirmingDelete -> "delete \"${entry.title}\"?"379            domain.isNotEmpty() -> domain380            else -> entry.title381        },382        leading = {383            Icon(384                Icons.AutoMirrored.Rounded.ArrowBack,385                contentDescription = "back",386                tint = peachColors.T0,387                modifier = Modifier388                    .size(40.dp)389                    .clip(CircleShape)390                    .clickable(onClick = onBack)391                    .padding(PeachSpacing.sm),392            )393            if (entry != null && !confirmingDelete) {394                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)395            }396        },397        actions = {398            if (entry != null) {399                Icon(400                    Icons.Outlined.DeleteOutline,401                    contentDescription = "delete",402                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,403                    modifier = Modifier404                        .size(40.dp)405                        .clip(CircleShape)406                        .clickable(onClick = onDelete)407                        .padding(PeachSpacing.sm),408                )409            }410        },411    )412}413414@Composable415private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {416    val accent = LocalPeachAccent.current417    var editing by remember(title) { mutableStateOf(false) }418    var draft by remember(title) { mutableStateOf(title) }419    if (!editing) {420        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {421            CharEnterText(422                text = title,423                style = PeachType.entryNameHero.copy(color = peachColors.T0),424            )425        }426        return427    }428    Column(Modifier.fillMaxWidth()) {429        BasicTextField(430            value = draft,431            onValueChange = { draft = it },432            singleLine = true,433            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),434            cursorBrush = SolidColor(accent),435            modifier = Modifier.fillMaxWidth().drawBehind {436                drawLine(437                    color = accent,438                    start = Offset(0f, size.height),439                    end = Offset(size.width, size.height),440                    strokeWidth = 1.dp.toPx(),441                )442            },443        )444        Spacer(Modifier.height(8.dp))445        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {446            Text(447                "✓ save", color = accent, style = PeachType.labelMono,448                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {449                    onSave(draft)450                    editing = false451                }.padding(vertical = 4.dp),452            )453            Text(454                "✗", color = peachColors.T2, style = PeachType.labelMono,455                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {456                    draft = title457                    editing = false458                }.padding(horizontal = 6.dp, vertical = 4.dp),459            )460        }461    }462}463464@Composable465private fun HomographWarning() {466    Row(467        Modifier.fillMaxWidth()468            .padding(top = 8.dp)469            .clip(RoundedCornerShape(8.dp))470            .background(peachColors.BreachBg)471            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))472            .padding(horizontal = 12.dp, vertical = 9.dp),473        verticalAlignment = Alignment.CenterVertically,474    ) {475        Text("!", color = peachColors.Breach, style = PeachType.labelMono)476        Spacer(Modifier.width(10.dp))477        Text(478            "This URL contains non-ASCII or punycode characters.",479            color = peachColors.Breach,480            style = PeachType.labelMono,481            modifier = Modifier.weight(1f),482        )483    }484}485486@Composable487private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {488    val passkey = entry.passkey ?: return489    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {490        Column(Modifier.weight(1f)) {491            Text(492                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,493                color = peachColors.T0,494                style = PeachType.body,495            )496            Text(497                listOfNotNull(498                    passkey.rpId,499                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,500                ).joinToString(" · "),501                color = peachColors.T2,502                style = PeachType.labelMono,503            )504            Text(505                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",506                color = peachColors.T2,507                style = PeachType.labelMono,508                modifier = Modifier.padding(top = 3.dp),509            )510        }511    }512}513514@Composable515private fun CanaryDetailSection(516    entryId: String,517    modifiedAt: Long,518    canary: com.peachpasswords.vault.Canary?,519    isPro: Boolean,520    message: String?,521    testing: Boolean,522    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,523    onTest: (webhookUrl: String) -> Unit,524) {525    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }526    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }527    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }528    val gated = !isPro && canary == null529    FieldShell("canary alert") {530        Column(Modifier.weight(1f)) {531            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {532                Text(533                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",534                    color = peachColors.T0,535                    style = PeachType.body,536                    modifier = Modifier.weight(1f),537                )538                if (gated) {539                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)540                }541                Text(542                    if (enabled) "on" else "off",543                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,544                    style = PeachType.labelMono,545                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {546                        if (gated) {547                            onSave(true, webhookUrl, label)548                        } else {549                            val next = !enabled550                            enabled = next551                            if (!next) onSave(false, webhookUrl, label)552                        }553                    }.padding(horizontal = 6.dp, vertical = 4.dp),554                )555            }556            Text(557                "Fires a silent webhook when this entry's password or username is copied.",558                color = peachColors.T2,559                style = PeachType.labelMono,560                modifier = Modifier.padding(top = 3.dp),561            )562            if (enabled) {563                Spacer(Modifier.height(8.dp))564                PeachField(565                    label = "webhook url",566                    value = webhookUrl,567                    onChange = { webhookUrl = it },568                )569                Text(570                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",571                    color = peachColors.T2,572                    style = PeachType.labelMono,573                )574                PeachField(575                    label = "label",576                    value = label,577                    onChange = { label = it },578                    mono = false,579                )580                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {581                    Text(582                        "save",583                        color = LocalPeachAccent.current,584                        style = PeachType.labelMono,585                        modifier = Modifier.clip(RoundedCornerShape(6.dp))586                            .clickable { onSave(true, webhookUrl, label) }587                            .padding(vertical = 4.dp),588                    )589                    Text(590                        if (testing) "sending..." else "test ping",591                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,592                        style = PeachType.labelMono,593                        modifier = Modifier.clip(RoundedCornerShape(6.dp))594                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }595                            .padding(vertical = 4.dp),596                    )597                }598            }599            message?.let {600                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))601            }602            if (canary?.lastPinged != null || canary?.pingCount != null) {603                Text(604                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),605                    color = peachColors.T2,606                    style = PeachType.labelMono,607                    modifier = Modifier.padding(top = 3.dp),608                )609            }610        }611    }612}613614@Composable615private fun EntryDetailSearchStrip(616    currentIndex: Int,617    totalEntries: Int,618    onOpenList: () -> 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            .navigationBarsPadding()631            .padding(horizontal = 16.dp, vertical = 12.dp),632    ) {633        Row(634            Modifier635                .fillMaxWidth()636                .height(56.dp)637                .clip(RoundedCornerShape(16.dp))638                .background(peachColors.S1)639                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))640                .padding(horizontal = 12.dp),641            verticalAlignment = Alignment.CenterVertically,642        ) {643            Row(644                Modifier645                    .weight(1f)646                    .fillMaxHeight()647                    .clickable(onClick = onOpenList)648                    .pointerInput(onOpenList) {649                        var opened = false650                        detectVerticalDragGestures(651                            onDragEnd = { opened = false },652                            onDragCancel = { opened = false },653                        ) { _, dragAmount ->654                            if (!opened && dragAmount < -4f) {655                                opened = true656                                onOpenList()657                            }658                        }659                    },660                verticalAlignment = Alignment.CenterVertically,661            ) {662                Icon(663                    Icons.Rounded.Search,664                    contentDescription = null,665                    tint = accent,666                    modifier = Modifier.size(14.dp),667                )668                Spacer(Modifier.width(8.dp))669                Text(670                    "search vault...",671                    color = peachColors.T2,672                    style = PeachType.searchInput,673                    modifier = Modifier.weight(1f),674                )675                Text(676                    counter,677                    color = peachColors.T2,678                    style = PeachType.labelMono,679                )680            }681            Spacer(Modifier.width(8.dp))682            Icon(683                Icons.AutoMirrored.Rounded.ArrowBack,684                contentDescription = "Previous entry",685                tint = if (hasEntries) peachColors.T0 else peachColors.T2,686                modifier = Modifier687                    .size(38.dp)688                    .clip(CircleShape)689                    .clickable(enabled = hasEntries, onClick = onPrevious)690                    .padding(9.dp),691            )692            Icon(693                Icons.AutoMirrored.Rounded.ArrowForward,694                contentDescription = "Next entry",695                tint = if (hasEntries) peachColors.T0 else peachColors.T2,696                modifier = Modifier697                    .size(38.dp)698                    .clip(CircleShape)699                    .clickable(enabled = hasEntries, onClick = onNext)700                    .padding(9.dp),701            )702        }703    }704}705706/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */707@Composable708internal fun FieldShell(709    label: String,710    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,711) {712    val hairline = peachColors.T3713    Column(714        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {715            drawRect(716                color = hairline,717                topLeft = Offset(0f, size.height - 1.dp.toPx()),718                size = Size(size.width, 1.dp.toPx()),719            )720        },721    ) {722        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))723        Spacer(Modifier.height(6.dp))724        Row(verticalAlignment = Alignment.CenterVertically, content = content)725    }726}727728/**729 * Text for the ghost PeachScript watermark behind the entry name: the full title,730 * falling back to "P" for a blank title so the hero never renders empty.731 */732internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }733734internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =735    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)736737internal fun shortCredential(credentialId: String): String {738    if (credentialId.length <= 12) return credentialId739    return credentialId.take(6) + "..." + credentialId.takeLast(4)740}741742internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {743    val count = pingCount ?: 0744    val countLabel = "$count total alert" + if (count == 1) "" else "s"745    if (lastPinged == null) return countLabel746    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)747        .format(java.util.Date(lastPinged))748    return "Last triggered: $date · $countLabel"749}750751private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {752    val raw = url753    if (!raw.isNullOrBlank()) {754        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')755        if (cleaned.isNotEmpty()) return cleaned756    }757    return ""758}