EntryDetailScreen.kt

33.4 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.material.icons.outlined.Lock43import androidx.compose.material3.Icon44import androidx.compose.material3.Text45import androidx.compose.runtime.Composable46import androidx.compose.runtime.getValue47import androidx.compose.runtime.mutableStateOf48import androidx.compose.runtime.produceState49import androidx.compose.runtime.remember50import androidx.compose.runtime.setValue51import androidx.compose.ui.Alignment52import androidx.compose.ui.Modifier53import androidx.compose.ui.draw.clip54import androidx.compose.ui.draw.clipToBounds55import androidx.compose.ui.draw.drawBehind56import androidx.compose.ui.geometry.Offset57import androidx.compose.ui.geometry.Size58import androidx.compose.ui.graphics.Brush59import androidx.compose.ui.graphics.Color60import androidx.compose.ui.graphics.SolidColor61import androidx.compose.ui.graphics.graphicsLayer62import androidx.compose.ui.platform.LocalContext63import androidx.compose.ui.input.pointer.pointerInput64import androidx.compose.ui.text.TextStyle65import androidx.compose.ui.text.font.FontStyle66import androidx.compose.ui.text.style.TextOverflow67import androidx.compose.ui.unit.dp68import androidx.compose.ui.unit.em69import androidx.compose.ui.unit.sp70import androidx.compose.runtime.collectAsState71import androidx.fragment.app.FragmentActivity72import androidx.hilt.navigation.compose.hiltViewModel73import com.peachpasswords.shared.detectHomograph74import com.peachpasswords.ui.PeachNav75import com.peachpasswords.ui.components.CharEnterText76import com.peachpasswords.ui.components.CopyButton77import com.peachpasswords.ui.components.FaviconInitials78import com.peachpasswords.ui.components.PeachEmptyOrEndState79import com.peachpasswords.ui.components.PeachTopBar80import com.peachpasswords.ui.components.SecureFlagEffect81import com.peachpasswords.ui.components.TOTPRing82import com.peachpasswords.ui.lock.Biometrics83import com.peachpasswords.ui.theme.LocalPeachAccent84import com.peachpasswords.ui.theme.LocalReduceMotion85import com.peachpasswords.ui.theme.PeachSpacing86import com.peachpasswords.ui.theme.peachColors87import com.peachpasswords.ui.theme.PeachType88import com.peachpasswords.ui.theme.ShellBackground89import com.peachpasswords.vault.TOTPGenerator90import com.peachpasswords.vault.hasTOTP91import com.peachpasswords.vault.hasPasskey92import com.peachpasswords.vault.isBreach93import com.peachpasswords.vault.isCard94import com.peachpasswords.vault.isNote95import com.peachpasswords.vault.isPasskeyOnly9697@Composable98fun EntryDetailScreen(99    id: String,100    onNavigate: (PeachNav) -> Unit,101    // Null when this entry is the post-unlock landing surface (C1: entry deck102    // is primary, not a child of the vault list) — there's genuinely nothing103    // to go "back" to, so the top chrome shows a lock affordance instead.104    onBack: (() -> Unit)?,105    vm: EntryDetailViewModel = hiltViewModel(),106) {107    val entry by vm.entry.collectAsState()108    val activeEntries by vm.activeEntries.collectAsState()109    val identitySuggestions by vm.identitySuggestions.collectAsState()110    val allTags by vm.allTags.collectAsState()111    val isPro by vm.isPro.collectAsState()112    val cvcRevealed by vm.cvcRevealed.collectAsState()113    var cardRevealed by remember { mutableStateOf(false) }114    val copiedField by vm.copiedField.collectAsState()115    val canaryMessage by vm.canaryMessage.collectAsState()116    val canaryTesting by vm.canaryTesting.collectAsState()117    val context = LocalContext.current118    val accent = LocalPeachAccent.current119    var confirmingDelete by remember { mutableStateOf(false) }120    var generatorOpen by remember { mutableStateOf(false) }121    var historyExpanded by remember { mutableStateOf(false) }122123    SecureFlagEffect()124125    val e = entry126    ShellBackground {127        Box(Modifier.fillMaxSize().statusBarsPadding()) {128            Column(129                Modifier.fillMaxSize()130                    .padding(bottom = 100.dp)131                    .verticalScroll(rememberScrollState())132                    .padding(horizontal = PeachSpacing.xl),133            ) {134                EntryDetailTopBar(135                    entry = e,136                    confirmingDelete = confirmingDelete,137                    onBack = onBack,138                    onLock = vm::lock,139                    onDelete = {140                        if (confirmingDelete) {141                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })142                        } else {143                            confirmingDelete = true144                        }145                    },146                )147                Spacer(Modifier.height(PeachSpacing.lg))148149                if (e == null) {150                    PeachEmptyOrEndState(151                        message = "entry not found",152                        detail = "It may have been moved or deleted.",153                        modifier = Modifier.padding(top = PeachSpacing.xxl),154                    )155                    return@Column156                }157158                // D11 — breach banner strip when this entry's password is known-compromised.159                if (e.isBreach()) {160                    Row(161                        Modifier.fillMaxWidth()162                            .background(peachColors.BreachBg)163                            .padding(horizontal = 16.dp, vertical = 8.dp),164                        verticalAlignment = Alignment.CenterVertically,165                    ) {166                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)167                        Spacer(Modifier.width(10.dp))168                        Text(169                            "Password found in a data breach — change it now",170                            color = peachColors.Breach, style = PeachType.labelMono,171                            modifier = Modifier.weight(1f),172                        )173                        Text(174                            "Change password",175                            color = accent, style = PeachType.labelMono,176                            modifier = Modifier.clip(RoundedCornerShape(6.dp))177                                .clickable { generatorOpen = true }178                                .padding(horizontal = 6.dp, vertical = 4.dp),179                        )180                    }181                }182                if (entryHasHomographWarning(e)) {183                    HomographWarning()184                }185186                // Hero — ghost PeachScript watermark behind the entry name. The full187                // title is drawn (not just its first letter); a long title overflows188                // and clips naturally, which is the intended watermark behavior.189                Box(Modifier.fillMaxWidth().clipToBounds()) {190                    Text(191                        text = ghostWatermarkText(e.title),192                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),193                        color = peachColors.T0.copy(alpha = 0.03f),194                        maxLines = 1,195                        softWrap = false,196                        overflow = TextOverflow.Clip,197                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),198                    )199                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {200                        EditableTitleHero(201                            title = e.title,202                            onSave = { vm.updateField("title", it) },203                        )204                    }205                }206                Spacer(Modifier.height(PeachSpacing.lg))207208                if (e.isCard()) {209                    CardDetailFields(210                        entry = e,211                        cardRevealed = cardRevealed,212                        cvcRevealed = cvcRevealed,213                        copiedField = copiedField,214                        onToggleCardReveal = { cardRevealed = !cardRevealed },215                        onRevealCvc = {216                            val activity = context as? FragmentActivity217                            if (activity != null && Biometrics.isAvailable(context)) {218                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)219                            } else vm.revealCvc()220                        },221                        onMaskCvc = vm::maskCvc,222                        onCopy = vm::copy,223                        onSaveCard = vm::updateCard,224                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },225                    )226                } else {227                    if (!e.isNote()) {228                        EditableFieldRow(229                            label = "url", field = "url", value = e.url.orEmpty(),230                            copied = copiedField == "url",231                            onCopy = { vm.copy("url", e.url) },232                            onSave = { vm.updateField("url", it) },233                            placeholder = "add url",234                        )235                    }236                    e.username?.takeIf { it.isNotBlank() }?.let { v ->237                        EditableFieldRow(238                            label = "username", field = "username", value = v,239                            copied = copiedField == "username",240                            onCopy = { vm.copy("username", v) },241                            onSave = { vm.updateField("username", it) },242                            suggestions = identitySuggestions,243                        )244                    }245                    e.email?.takeIf { it.isNotBlank() }?.let { v ->246                        EditableFieldRow(247                            label = "email", field = "email", value = v,248                            copied = copiedField == "email",249                            onCopy = { vm.copy("email", v) },250                            onSave = { vm.updateField("email", it) },251                            suggestions = identitySuggestions,252                        )253                    }254                    AdditionalUrlRows(255                        urls = e.additionalUrls.orEmpty(),256                        copiedField = copiedField,257                        onCopy = { key, value -> vm.copy(key, value) },258                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },259                        onAdd = vm::addAdditionalUrl,260                    )261                    if (e.hasPasskey()) {262                        PasskeyDetailSection(e)263                    }264                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->265                        EditablePasswordRow(266                            value = pw,267                            copied = copiedField == "password",268                            entryTitle = e.title,269                            onCopy = { vm.copy("password", pw) },270                            onSave = { vm.updateField("password", it) },271                            onGenerate = { generatorOpen = true },272                        )273                    }274                    val history = e.passwordHistory.orEmpty()275                    if (history.isNotEmpty()) {276                        PasswordHistoryToggle(277                            count = history.size,278                            expanded = historyExpanded,279                            onToggle = { historyExpanded = !historyExpanded },280                        )281                        AnimatedVisibility(visible = historyExpanded) {282                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {283                                PasswordHistoryList(284                                    history = history,285                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },286                                    onCopy = { oldPw -> vm.copy("password", oldPw) },287                                )288                            }289                        }290                    }291                    if (e.hasTOTP()) {292                        Spacer(Modifier.height(4.dp))293                        TotpBandEditable(294                            totpField = e.totp!!,295                            copied = copiedField == "totp",296                            onCopy = vm::copyTotp,297                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },298                        )299                    }300                }301                e.notes?.takeIf { it.isNotBlank() }?.let { v ->302                    EditableFieldRow(303                        label = "notes", field = "notes", value = v,304                        copied = copiedField == "notes",305                        onCopy = { vm.copy("notes", v) },306                        onSave = { vm.updateField("notes", it) },307                        mono = false, singleLine = false,308                    )309                }310                DetailTagRow(311                    tags = e.tags.orEmpty(),312                    allTags = allTags,313                    onAddTag = vm::addTag,314                    onRemoveTag = vm::removeTag,315                )316                if (!e.isNote() && !e.isCard()) {317                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),318                    // unless a canary is already configured for this entry — every319                    // entry showing an "Off" canary block permanently was clutter320                    // most people never touch.321                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }322                    if (advancedExpanded) {323                        CanaryDetailSection(324                            entryId = e.id,325                            modifiedAt = e.modifiedAt,326                            canary = e.canary,327                            isPro = isPro,328                            message = canaryMessage,329                            testing = canaryTesting,330                            onSave = vm::saveCanary,331                            onTest = vm::testCanary,332                        )333                    } else {334                        Text(335                            "+ advanced",336                            color = LocalPeachAccent.current,337                            style = PeachType.labelMono,338                            modifier = Modifier339                                .clip(RoundedCornerShape(6.dp))340                                .clickable { advancedExpanded = true }341                                .padding(vertical = 6.dp),342                        )343                    }344                }345346                Spacer(Modifier.height(PeachSpacing.xxl))347            }348349            if (e != null) {350                EntryDetailSearchStrip(351                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },352                    totalEntries = activeEntries.size,353                    onOpenList = { onNavigate(PeachNav.VaultList) },354                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },355                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },356                    modifier = Modifier.align(Alignment.BottomCenter),357                )358            }359360            if (generatorOpen) {361                GeneratorSheet(362                    copied = copiedField == "genDraft",363                    onCopy = { draft -> vm.copy("genDraft", draft) },364                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },365                    onDismiss = { generatorOpen = false },366                )367            }368        }369    }370}371372@Composable373private fun EntryDetailTopBar(374    entry: com.peachpasswords.vault.VaultEntry?,375    confirmingDelete: Boolean,376    onBack: (() -> Unit)?,377    onLock: () -> Unit,378    onDelete: () -> Unit,379) {380    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }381    PeachTopBar(382        title = when {383            entry == null -> "entry"384            confirmingDelete -> "delete \"${entry.title}\"?"385            domain.isNotEmpty() -> domain386            else -> entry.title387        },388        leading = {389            if (onBack != null) {390                Icon(391                    Icons.AutoMirrored.Rounded.ArrowBack,392                    contentDescription = "back",393                    tint = peachColors.T0,394                    modifier = Modifier395                        .size(40.dp)396                        .clip(CircleShape)397                        .clickable(onClick = onBack)398                        .padding(PeachSpacing.sm),399                )400            } else {401                // This entry is the post-unlock landing surface, not pushed402                // from the vault list — there's nothing to go "back" to, so403                // the chrome offers the one action that actually makes sense404                // here instead (lock vault list's own affordance). Same405                // 40dp tap target as the back arrow, but the padlock glyph406                // reads visually heavier than the thin arrow chevron at the407                // same box size, so it gets a touch more inset to match.408                Icon(409                    Icons.Outlined.Lock,410                    contentDescription = "Lock vault",411                    tint = peachColors.T0,412                    modifier = Modifier413                        .size(40.dp)414                        .clip(CircleShape)415                        .clickable(onClick = onLock)416                        .padding(11.dp),417                )418            }419            if (entry != null && !confirmingDelete) {420                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)421            }422        },423        actions = {424            if (entry != null) {425                Icon(426                    Icons.Outlined.DeleteOutline,427                    contentDescription = "delete",428                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,429                    modifier = Modifier430                        .size(40.dp)431                        .clip(CircleShape)432                        .clickable(onClick = onDelete)433                        .padding(PeachSpacing.sm),434                )435            }436        },437    )438}439440@Composable441private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {442    val accent = LocalPeachAccent.current443    var editing by remember(title) { mutableStateOf(false) }444    var draft by remember(title) { mutableStateOf(title) }445    if (!editing) {446        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {447            CharEnterText(448                text = title,449                style = PeachType.entryNameHero.copy(color = peachColors.T0),450            )451        }452        return453    }454    Column(Modifier.fillMaxWidth()) {455        BasicTextField(456            value = draft,457            onValueChange = { draft = it },458            singleLine = true,459            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),460            cursorBrush = SolidColor(accent),461            modifier = Modifier.fillMaxWidth().drawBehind {462                drawLine(463                    color = accent,464                    start = Offset(0f, size.height),465                    end = Offset(size.width, size.height),466                    strokeWidth = 1.dp.toPx(),467                )468            },469        )470        Spacer(Modifier.height(8.dp))471        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {472            Text(473                "✓ save", color = accent, style = PeachType.labelMono,474                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {475                    onSave(draft)476                    editing = false477                }.padding(vertical = 4.dp),478            )479            Text(480                "✗", color = peachColors.T2, style = PeachType.labelMono,481                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {482                    draft = title483                    editing = false484                }.padding(horizontal = 6.dp, vertical = 4.dp),485            )486        }487    }488}489490@Composable491private fun HomographWarning() {492    Row(493        Modifier.fillMaxWidth()494            .padding(top = 8.dp)495            .clip(RoundedCornerShape(8.dp))496            .background(peachColors.BreachBg)497            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))498            .padding(horizontal = 12.dp, vertical = 9.dp),499        verticalAlignment = Alignment.CenterVertically,500    ) {501        Text("!", color = peachColors.Breach, style = PeachType.labelMono)502        Spacer(Modifier.width(10.dp))503        Text(504            "This URL contains non-ASCII or punycode characters.",505            color = peachColors.Breach,506            style = PeachType.labelMono,507            modifier = Modifier.weight(1f),508        )509    }510}511512@Composable513private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {514    val passkey = entry.passkey ?: return515    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {516        Column(Modifier.weight(1f)) {517            Text(518                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,519                color = peachColors.T0,520                style = PeachType.body,521            )522            Text(523                listOfNotNull(524                    passkey.rpId,525                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,526                ).joinToString(" · "),527                color = peachColors.T2,528                style = PeachType.labelMono,529            )530            Text(531                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",532                color = peachColors.T2,533                style = PeachType.labelMono,534                modifier = Modifier.padding(top = 3.dp),535            )536        }537    }538}539540@Composable541private fun CanaryDetailSection(542    entryId: String,543    modifiedAt: Long,544    canary: com.peachpasswords.vault.Canary?,545    isPro: Boolean,546    message: String?,547    testing: Boolean,548    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,549    onTest: (webhookUrl: String) -> Unit,550) {551    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }552    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }553    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }554    val gated = !isPro && canary == null555    FieldShell("canary alert") {556        Column(Modifier.weight(1f)) {557            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {558                Text(559                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",560                    color = peachColors.T0,561                    style = PeachType.body,562                    modifier = Modifier.weight(1f),563                )564                if (gated) {565                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)566                }567                Text(568                    if (enabled) "on" else "off",569                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,570                    style = PeachType.labelMono,571                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {572                        if (gated) {573                            onSave(true, webhookUrl, label)574                        } else {575                            val next = !enabled576                            enabled = next577                            if (!next) onSave(false, webhookUrl, label)578                        }579                    }.padding(horizontal = 6.dp, vertical = 4.dp),580                )581            }582            Text(583                "Fires a silent webhook when this entry's password or username is copied.",584                color = peachColors.T2,585                style = PeachType.labelMono,586                modifier = Modifier.padding(top = 3.dp),587            )588            if (enabled) {589                Spacer(Modifier.height(8.dp))590                PeachField(591                    label = "webhook url",592                    value = webhookUrl,593                    onChange = { webhookUrl = it },594                )595                Text(596                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",597                    color = peachColors.T2,598                    style = PeachType.labelMono,599                )600                PeachField(601                    label = "label",602                    value = label,603                    onChange = { label = it },604                    mono = false,605                )606                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {607                    Text(608                        "save",609                        color = LocalPeachAccent.current,610                        style = PeachType.labelMono,611                        modifier = Modifier.clip(RoundedCornerShape(6.dp))612                            .clickable { onSave(true, webhookUrl, label) }613                            .padding(vertical = 4.dp),614                    )615                    Text(616                        if (testing) "sending..." else "test ping",617                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,618                        style = PeachType.labelMono,619                        modifier = Modifier.clip(RoundedCornerShape(6.dp))620                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }621                            .padding(vertical = 4.dp),622                    )623                }624            }625            message?.let {626                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))627            }628            if (canary?.lastPinged != null || canary?.pingCount != null) {629                Text(630                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),631                    color = peachColors.T2,632                    style = PeachType.labelMono,633                    modifier = Modifier.padding(top = 3.dp),634                )635            }636        }637    }638}639640@Composable641private fun EntryDetailSearchStrip(642    currentIndex: Int,643    totalEntries: Int,644    onOpenList: () -> Unit,645    onPrevious: () -> Unit,646    onNext: () -> Unit,647    modifier: Modifier = Modifier,648) {649    val accent = LocalPeachAccent.current650    val hasEntries = totalEntries > 0 && currentIndex >= 0651    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"652653    Column(654        modifier655            .fillMaxWidth()656            .navigationBarsPadding()657            .padding(horizontal = 16.dp, vertical = 12.dp),658    ) {659        Row(660            Modifier661                .fillMaxWidth()662                .height(56.dp)663                .clip(RoundedCornerShape(16.dp))664                .background(peachColors.S1)665                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))666                .padding(horizontal = 12.dp),667            verticalAlignment = Alignment.CenterVertically,668        ) {669            Row(670                Modifier671                    .weight(1f)672                    .fillMaxHeight()673                    .clickable(onClick = onOpenList)674                    .pointerInput(onOpenList) {675                        var opened = false676                        detectVerticalDragGestures(677                            onDragEnd = { opened = false },678                            onDragCancel = { opened = false },679                        ) { _, dragAmount ->680                            if (!opened && dragAmount < -4f) {681                                opened = true682                                onOpenList()683                            }684                        }685                    },686                verticalAlignment = Alignment.CenterVertically,687            ) {688                Icon(689                    Icons.Rounded.Search,690                    contentDescription = null,691                    tint = accent,692                    modifier = Modifier.size(14.dp),693                )694                Spacer(Modifier.width(8.dp))695                Text(696                    "search vault...",697                    color = peachColors.T2,698                    style = PeachType.searchInput,699                    modifier = Modifier.weight(1f),700                )701                Text(702                    counter,703                    color = peachColors.T2,704                    style = PeachType.labelMono,705                )706            }707            Spacer(Modifier.width(8.dp))708            Icon(709                Icons.AutoMirrored.Rounded.ArrowBack,710                contentDescription = "Previous entry",711                tint = if (hasEntries) peachColors.T0 else peachColors.T2,712                modifier = Modifier713                    .size(38.dp)714                    .clip(CircleShape)715                    .clickable(enabled = hasEntries, onClick = onPrevious)716                    .padding(9.dp),717            )718            Icon(719                Icons.AutoMirrored.Rounded.ArrowForward,720                contentDescription = "Next entry",721                tint = if (hasEntries) peachColors.T0 else peachColors.T2,722                modifier = Modifier723                    .size(38.dp)724                    .clip(CircleShape)725                    .clickable(enabled = hasEntries, onClick = onNext)726                    .padding(9.dp),727            )728        }729    }730}731732/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */733@Composable734internal fun FieldShell(735    label: String,736    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,737) {738    val hairline = peachColors.T3739    Column(740        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {741            drawRect(742                color = hairline,743                topLeft = Offset(0f, size.height - 1.dp.toPx()),744                size = Size(size.width, 1.dp.toPx()),745            )746        },747    ) {748        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))749        Spacer(Modifier.height(6.dp))750        Row(verticalAlignment = Alignment.CenterVertically, content = content)751    }752}753754/**755 * Text for the ghost PeachScript watermark behind the entry name: the full title,756 * falling back to "P" for a blank title so the hero never renders empty.757 */758internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }759760internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =761    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)762763internal fun shortCredential(credentialId: String): String {764    if (credentialId.length <= 12) return credentialId765    return credentialId.take(6) + "..." + credentialId.takeLast(4)766}767768internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {769    val count = pingCount ?: 0770    val countLabel = "$count total alert" + if (count == 1) "" else "s"771    if (lastPinged == null) return countLabel772    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)773        .format(java.util.Date(lastPinged))774    return "Last triggered: $date · $countLabel"775}776777private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {778    val raw = url779    if (!raw.isNullOrBlank()) {780        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')781        if (cleaned.isNotEmpty()) return cleaned782    }783    return ""784}