EntryDetailScreen.kt

33.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.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).405                Icon(406                    Icons.Outlined.Lock,407                    contentDescription = "Lock vault",408                    tint = peachColors.T0,409                    modifier = Modifier410                        .size(40.dp)411                        .clip(CircleShape)412                        .clickable(onClick = onLock)413                        .padding(PeachSpacing.sm),414                )415            }416            if (entry != null && !confirmingDelete) {417                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)418            }419        },420        actions = {421            if (entry != null) {422                Icon(423                    Icons.Outlined.DeleteOutline,424                    contentDescription = "delete",425                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,426                    modifier = Modifier427                        .size(40.dp)428                        .clip(CircleShape)429                        .clickable(onClick = onDelete)430                        .padding(PeachSpacing.sm),431                )432            }433        },434    )435}436437@Composable438private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {439    val accent = LocalPeachAccent.current440    var editing by remember(title) { mutableStateOf(false) }441    var draft by remember(title) { mutableStateOf(title) }442    if (!editing) {443        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {444            CharEnterText(445                text = title,446                style = PeachType.entryNameHero.copy(color = peachColors.T0),447            )448        }449        return450    }451    Column(Modifier.fillMaxWidth()) {452        BasicTextField(453            value = draft,454            onValueChange = { draft = it },455            singleLine = true,456            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),457            cursorBrush = SolidColor(accent),458            modifier = Modifier.fillMaxWidth().drawBehind {459                drawLine(460                    color = accent,461                    start = Offset(0f, size.height),462                    end = Offset(size.width, size.height),463                    strokeWidth = 1.dp.toPx(),464                )465            },466        )467        Spacer(Modifier.height(8.dp))468        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {469            Text(470                "✓ save", color = accent, style = PeachType.labelMono,471                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {472                    onSave(draft)473                    editing = false474                }.padding(vertical = 4.dp),475            )476            Text(477                "✗", color = peachColors.T2, style = PeachType.labelMono,478                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {479                    draft = title480                    editing = false481                }.padding(horizontal = 6.dp, vertical = 4.dp),482            )483        }484    }485}486487@Composable488private fun HomographWarning() {489    Row(490        Modifier.fillMaxWidth()491            .padding(top = 8.dp)492            .clip(RoundedCornerShape(8.dp))493            .background(peachColors.BreachBg)494            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))495            .padding(horizontal = 12.dp, vertical = 9.dp),496        verticalAlignment = Alignment.CenterVertically,497    ) {498        Text("!", color = peachColors.Breach, style = PeachType.labelMono)499        Spacer(Modifier.width(10.dp))500        Text(501            "This URL contains non-ASCII or punycode characters.",502            color = peachColors.Breach,503            style = PeachType.labelMono,504            modifier = Modifier.weight(1f),505        )506    }507}508509@Composable510private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {511    val passkey = entry.passkey ?: return512    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {513        Column(Modifier.weight(1f)) {514            Text(515                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,516                color = peachColors.T0,517                style = PeachType.body,518            )519            Text(520                listOfNotNull(521                    passkey.rpId,522                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,523                ).joinToString(" · "),524                color = peachColors.T2,525                style = PeachType.labelMono,526            )527            Text(528                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",529                color = peachColors.T2,530                style = PeachType.labelMono,531                modifier = Modifier.padding(top = 3.dp),532            )533        }534    }535}536537@Composable538private fun CanaryDetailSection(539    entryId: String,540    modifiedAt: Long,541    canary: com.peachpasswords.vault.Canary?,542    isPro: Boolean,543    message: String?,544    testing: Boolean,545    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,546    onTest: (webhookUrl: String) -> Unit,547) {548    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }549    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }550    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }551    val gated = !isPro && canary == null552    FieldShell("canary alert") {553        Column(Modifier.weight(1f)) {554            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {555                Text(556                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",557                    color = peachColors.T0,558                    style = PeachType.body,559                    modifier = Modifier.weight(1f),560                )561                if (gated) {562                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)563                }564                Text(565                    if (enabled) "on" else "off",566                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,567                    style = PeachType.labelMono,568                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {569                        if (gated) {570                            onSave(true, webhookUrl, label)571                        } else {572                            val next = !enabled573                            enabled = next574                            if (!next) onSave(false, webhookUrl, label)575                        }576                    }.padding(horizontal = 6.dp, vertical = 4.dp),577                )578            }579            Text(580                "Fires a silent webhook when this entry's password or username is copied.",581                color = peachColors.T2,582                style = PeachType.labelMono,583                modifier = Modifier.padding(top = 3.dp),584            )585            if (enabled) {586                Spacer(Modifier.height(8.dp))587                PeachField(588                    label = "webhook url",589                    value = webhookUrl,590                    onChange = { webhookUrl = it },591                )592                Text(593                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",594                    color = peachColors.T2,595                    style = PeachType.labelMono,596                )597                PeachField(598                    label = "label",599                    value = label,600                    onChange = { label = it },601                    mono = false,602                )603                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {604                    Text(605                        "save",606                        color = LocalPeachAccent.current,607                        style = PeachType.labelMono,608                        modifier = Modifier.clip(RoundedCornerShape(6.dp))609                            .clickable { onSave(true, webhookUrl, label) }610                            .padding(vertical = 4.dp),611                    )612                    Text(613                        if (testing) "sending..." else "test ping",614                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,615                        style = PeachType.labelMono,616                        modifier = Modifier.clip(RoundedCornerShape(6.dp))617                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }618                            .padding(vertical = 4.dp),619                    )620                }621            }622            message?.let {623                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))624            }625            if (canary?.lastPinged != null || canary?.pingCount != null) {626                Text(627                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),628                    color = peachColors.T2,629                    style = PeachType.labelMono,630                    modifier = Modifier.padding(top = 3.dp),631                )632            }633        }634    }635}636637@Composable638private fun EntryDetailSearchStrip(639    currentIndex: Int,640    totalEntries: Int,641    onOpenList: () -> Unit,642    onPrevious: () -> Unit,643    onNext: () -> Unit,644    modifier: Modifier = Modifier,645) {646    val accent = LocalPeachAccent.current647    val hasEntries = totalEntries > 0 && currentIndex >= 0648    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"649650    Column(651        modifier652            .fillMaxWidth()653            .navigationBarsPadding()654            .padding(horizontal = 16.dp, vertical = 12.dp),655    ) {656        Row(657            Modifier658                .fillMaxWidth()659                .height(56.dp)660                .clip(RoundedCornerShape(16.dp))661                .background(peachColors.S1)662                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))663                .padding(horizontal = 12.dp),664            verticalAlignment = Alignment.CenterVertically,665        ) {666            Row(667                Modifier668                    .weight(1f)669                    .fillMaxHeight()670                    .clickable(onClick = onOpenList)671                    .pointerInput(onOpenList) {672                        var opened = false673                        detectVerticalDragGestures(674                            onDragEnd = { opened = false },675                            onDragCancel = { opened = false },676                        ) { _, dragAmount ->677                            if (!opened && dragAmount < -4f) {678                                opened = true679                                onOpenList()680                            }681                        }682                    },683                verticalAlignment = Alignment.CenterVertically,684            ) {685                Icon(686                    Icons.Rounded.Search,687                    contentDescription = null,688                    tint = accent,689                    modifier = Modifier.size(14.dp),690                )691                Spacer(Modifier.width(8.dp))692                Text(693                    "search vault...",694                    color = peachColors.T2,695                    style = PeachType.searchInput,696                    modifier = Modifier.weight(1f),697                )698                Text(699                    counter,700                    color = peachColors.T2,701                    style = PeachType.labelMono,702                )703            }704            Spacer(Modifier.width(8.dp))705            Icon(706                Icons.AutoMirrored.Rounded.ArrowBack,707                contentDescription = "Previous entry",708                tint = if (hasEntries) peachColors.T0 else peachColors.T2,709                modifier = Modifier710                    .size(38.dp)711                    .clip(CircleShape)712                    .clickable(enabled = hasEntries, onClick = onPrevious)713                    .padding(9.dp),714            )715            Icon(716                Icons.AutoMirrored.Rounded.ArrowForward,717                contentDescription = "Next entry",718                tint = if (hasEntries) peachColors.T0 else peachColors.T2,719                modifier = Modifier720                    .size(38.dp)721                    .clip(CircleShape)722                    .clickable(enabled = hasEntries, onClick = onNext)723                    .padding(9.dp),724            )725        }726    }727}728729/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */730@Composable731internal fun FieldShell(732    label: String,733    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,734) {735    val hairline = peachColors.T3736    Column(737        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {738            drawRect(739                color = hairline,740                topLeft = Offset(0f, size.height - 1.dp.toPx()),741                size = Size(size.width, 1.dp.toPx()),742            )743        },744    ) {745        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))746        Spacer(Modifier.height(6.dp))747        Row(verticalAlignment = Alignment.CenterVertically, content = content)748    }749}750751/**752 * Text for the ghost PeachScript watermark behind the entry name: the full title,753 * falling back to "P" for a blank title so the hero never renders empty.754 */755internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }756757internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =758    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)759760internal fun shortCredential(credentialId: String): String {761    if (credentialId.length <= 12) return credentialId762    return credentialId.take(6) + "..." + credentialId.takeLast(4)763}764765internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {766    val count = pingCount ?: 0767    val countLabel = "$count total alert" + if (count == 1) "" else "s"768    if (lastPinged == null) return countLabel769    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)770        .format(java.util.Date(lastPinged))771    return "Last triggered: $date · $countLabel"772}773774private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {775    val raw = url776    if (!raw.isNullOrBlank()) {777        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')778        if (cleaned.isNotEmpty()) return cleaned779    }780    return ""781}