EntryDetailScreen.kt

31.9 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                Spacer(Modifier.height(PeachSpacing.xxl))338            }339340            if (e != null) {341                EntryDetailSearchStrip(342                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },343                    totalEntries = activeEntries.size,344                    onOpenList = { onNavigate(PeachNav.VaultList) },345                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },346                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },347                    modifier = Modifier.align(Alignment.BottomCenter),348                )349            }350351            if (generatorOpen) {352                GeneratorSheet(353                    copied = copiedField == "genDraft",354                    onCopy = { draft -> vm.copy("genDraft", draft) },355                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },356                    onDismiss = { generatorOpen = false },357                )358            }359        }360    }361}362363@Composable364private fun EntryDetailTopBar(365    entry: com.peachpasswords.vault.VaultEntry?,366    confirmingDelete: Boolean,367    onBack: () -> Unit,368    onDelete: () -> Unit,369) {370    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }371    PeachTopBar(372        title = when {373            entry == null -> "entry"374            confirmingDelete -> "delete \"${entry.title}\"?"375            domain.isNotEmpty() -> domain376            else -> entry.title377        },378        leading = {379            Icon(380                Icons.AutoMirrored.Rounded.ArrowBack,381                contentDescription = "back",382                tint = peachColors.T0,383                modifier = Modifier384                    .size(40.dp)385                    .clip(CircleShape)386                    .clickable(onClick = onBack)387                    .padding(PeachSpacing.sm),388            )389            if (entry != null && !confirmingDelete) {390                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)391            }392        },393        actions = {394            if (entry != null) {395                Icon(396                    Icons.Outlined.DeleteOutline,397                    contentDescription = "delete",398                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,399                    modifier = Modifier400                        .size(40.dp)401                        .clip(CircleShape)402                        .clickable(onClick = onDelete)403                        .padding(PeachSpacing.sm),404                )405            }406        },407    )408}409410@Composable411private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {412    val accent = LocalPeachAccent.current413    var editing by remember(title) { mutableStateOf(false) }414    var draft by remember(title) { mutableStateOf(title) }415    if (!editing) {416        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {417            CharEnterText(418                text = title,419                style = PeachType.entryNameHero.copy(color = peachColors.T0),420            )421        }422        return423    }424    Column(Modifier.fillMaxWidth()) {425        BasicTextField(426            value = draft,427            onValueChange = { draft = it },428            singleLine = true,429            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),430            cursorBrush = SolidColor(accent),431            modifier = Modifier.fillMaxWidth().drawBehind {432                drawLine(433                    color = accent,434                    start = Offset(0f, size.height),435                    end = Offset(size.width, size.height),436                    strokeWidth = 1.dp.toPx(),437                )438            },439        )440        Spacer(Modifier.height(8.dp))441        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {442            Text(443                "✓ save", color = accent, style = PeachType.labelMono,444                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {445                    onSave(draft)446                    editing = false447                }.padding(vertical = 4.dp),448            )449            Text(450                "✗", color = peachColors.T2, style = PeachType.labelMono,451                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {452                    draft = title453                    editing = false454                }.padding(horizontal = 6.dp, vertical = 4.dp),455            )456        }457    }458}459460@Composable461private fun HomographWarning() {462    Row(463        Modifier.fillMaxWidth()464            .padding(top = 8.dp)465            .clip(RoundedCornerShape(8.dp))466            .background(peachColors.BreachBg)467            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))468            .padding(horizontal = 12.dp, vertical = 9.dp),469        verticalAlignment = Alignment.CenterVertically,470    ) {471        Text("!", color = peachColors.Breach, style = PeachType.labelMono)472        Spacer(Modifier.width(10.dp))473        Text(474            "This URL contains non-ASCII or punycode characters.",475            color = peachColors.Breach,476            style = PeachType.labelMono,477            modifier = Modifier.weight(1f),478        )479    }480}481482@Composable483private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {484    val passkey = entry.passkey ?: return485    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {486        Column(Modifier.weight(1f)) {487            Text(488                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,489                color = peachColors.T0,490                style = PeachType.body,491            )492            Text(493                listOfNotNull(494                    passkey.rpId,495                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,496                ).joinToString(" · "),497                color = peachColors.T2,498                style = PeachType.labelMono,499            )500            Text(501                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",502                color = peachColors.T2,503                style = PeachType.labelMono,504                modifier = Modifier.padding(top = 3.dp),505            )506        }507    }508}509510@Composable511private fun CanaryDetailSection(512    entryId: String,513    modifiedAt: Long,514    canary: com.peachpasswords.vault.Canary?,515    isPro: Boolean,516    message: String?,517    testing: Boolean,518    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,519    onTest: (webhookUrl: String) -> Unit,520) {521    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }522    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }523    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }524    val gated = !isPro && canary == null525    FieldShell("canary alert") {526        Column(Modifier.weight(1f)) {527            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {528                Text(529                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",530                    color = peachColors.T0,531                    style = PeachType.body,532                    modifier = Modifier.weight(1f),533                )534                if (gated) {535                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)536                }537                Text(538                    if (enabled) "on" else "off",539                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,540                    style = PeachType.labelMono,541                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {542                        if (gated) {543                            onSave(true, webhookUrl, label)544                        } else {545                            val next = !enabled546                            enabled = next547                            if (!next) onSave(false, webhookUrl, label)548                        }549                    }.padding(horizontal = 6.dp, vertical = 4.dp),550                )551            }552            Text(553                "Fires a silent webhook when this entry's password or username is copied.",554                color = peachColors.T2,555                style = PeachType.labelMono,556                modifier = Modifier.padding(top = 3.dp),557            )558            if (enabled) {559                Spacer(Modifier.height(8.dp))560                PeachField(561                    label = "webhook url",562                    value = webhookUrl,563                    onChange = { webhookUrl = it },564                )565                Text(566                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",567                    color = peachColors.T2,568                    style = PeachType.labelMono,569                )570                PeachField(571                    label = "label",572                    value = label,573                    onChange = { label = it },574                    mono = false,575                )576                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {577                    Text(578                        "save",579                        color = LocalPeachAccent.current,580                        style = PeachType.labelMono,581                        modifier = Modifier.clip(RoundedCornerShape(6.dp))582                            .clickable { onSave(true, webhookUrl, label) }583                            .padding(vertical = 4.dp),584                    )585                    Text(586                        if (testing) "sending..." else "test ping",587                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,588                        style = PeachType.labelMono,589                        modifier = Modifier.clip(RoundedCornerShape(6.dp))590                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }591                            .padding(vertical = 4.dp),592                    )593                }594            }595            message?.let {596                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))597            }598            if (canary?.lastPinged != null || canary?.pingCount != null) {599                Text(600                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),601                    color = peachColors.T2,602                    style = PeachType.labelMono,603                    modifier = Modifier.padding(top = 3.dp),604                )605            }606        }607    }608}609610@Composable611private fun EntryDetailSearchStrip(612    currentIndex: Int,613    totalEntries: Int,614    onOpenList: () -> Unit,615    onPrevious: () -> Unit,616    onNext: () -> Unit,617    modifier: Modifier = Modifier,618) {619    val accent = LocalPeachAccent.current620    val hasEntries = totalEntries > 0 && currentIndex >= 0621    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"622623    Column(624        modifier625            .fillMaxWidth()626            .navigationBarsPadding()627            .padding(horizontal = 16.dp, vertical = 12.dp),628    ) {629        Row(630            Modifier631                .fillMaxWidth()632                .height(56.dp)633                .clip(RoundedCornerShape(16.dp))634                .background(peachColors.S1)635                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))636                .padding(horizontal = 12.dp),637            verticalAlignment = Alignment.CenterVertically,638        ) {639            Row(640                Modifier641                    .weight(1f)642                    .fillMaxHeight()643                    .clickable(onClick = onOpenList)644                    .pointerInput(onOpenList) {645                        var opened = false646                        detectVerticalDragGestures(647                            onDragEnd = { opened = false },648                            onDragCancel = { opened = false },649                        ) { _, dragAmount ->650                            if (!opened && dragAmount < -4f) {651                                opened = true652                                onOpenList()653                            }654                        }655                    },656                verticalAlignment = Alignment.CenterVertically,657            ) {658                Icon(659                    Icons.Rounded.Search,660                    contentDescription = null,661                    tint = accent,662                    modifier = Modifier.size(14.dp),663                )664                Spacer(Modifier.width(8.dp))665                Text(666                    "search vault...",667                    color = peachColors.T2,668                    style = PeachType.searchInput,669                    modifier = Modifier.weight(1f),670                )671                Text(672                    counter,673                    color = peachColors.T2,674                    style = PeachType.labelMono,675                )676            }677            Spacer(Modifier.width(8.dp))678            Icon(679                Icons.AutoMirrored.Rounded.ArrowBack,680                contentDescription = "Previous entry",681                tint = if (hasEntries) peachColors.T0 else peachColors.T2,682                modifier = Modifier683                    .size(38.dp)684                    .clip(CircleShape)685                    .clickable(enabled = hasEntries, onClick = onPrevious)686                    .padding(9.dp),687            )688            Icon(689                Icons.AutoMirrored.Rounded.ArrowForward,690                contentDescription = "Next entry",691                tint = if (hasEntries) peachColors.T0 else peachColors.T2,692                modifier = Modifier693                    .size(38.dp)694                    .clip(CircleShape)695                    .clickable(enabled = hasEntries, onClick = onNext)696                    .padding(9.dp),697            )698        }699    }700}701702/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */703@Composable704internal fun FieldShell(705    label: String,706    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,707) {708    val hairline = peachColors.T3709    Column(710        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {711            drawRect(712                color = hairline,713                topLeft = Offset(0f, size.height - 1.dp.toPx()),714                size = Size(size.width, 1.dp.toPx()),715            )716        },717    ) {718        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))719        Spacer(Modifier.height(6.dp))720        Row(verticalAlignment = Alignment.CenterVertically, content = content)721    }722}723724/**725 * Text for the ghost PeachScript watermark behind the entry name: the full title,726 * falling back to "P" for a blank title so the hero never renders empty.727 */728internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }729730internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =731    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)732733internal fun shortCredential(credentialId: String): String {734    if (credentialId.length <= 12) return credentialId735    return credentialId.take(6) + "..." + credentialId.takeLast(4)736}737738internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {739    val count = pingCount ?: 0740    val countLabel = "$count total alert" + if (count == 1) "" else "s"741    if (lastPinged == null) return countLabel742    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)743        .format(java.util.Date(lastPinged))744    return "Last triggered: $date · $countLabel"745}746747private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {748    val raw = url749    if (!raw.isNullOrBlank()) {750        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')751        if (cleaned.isNotEmpty()) return cleaned752    }753    return ""754}