EntryDetailScreen.kt

34.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.detectHorizontalDragGestures15import androidx.compose.foundation.gestures.detectVerticalDragGestures16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.Column19import androidx.compose.foundation.layout.Row20import androidx.compose.foundation.layout.Spacer21import androidx.compose.foundation.layout.fillMaxSize22import androidx.compose.foundation.layout.fillMaxHeight23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.offset26import androidx.compose.foundation.layout.padding27import androidx.compose.foundation.layout.navigationBarsPadding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.layout.width31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.shape.CircleShape33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material.icons.rounded.Check36import androidx.compose.material.icons.rounded.ContentCopy37import androidx.compose.material.icons.rounded.Search38import androidx.compose.foundation.verticalScroll39import androidx.compose.material.icons.Icons40import androidx.compose.material.icons.automirrored.rounded.ArrowBack41import androidx.compose.material.icons.automirrored.rounded.ArrowForward42import androidx.compose.material.icons.outlined.DeleteOutline43import androidx.compose.material.icons.outlined.Lock44import androidx.compose.material3.Icon45import androidx.compose.material3.Text46import androidx.compose.runtime.Composable47import androidx.compose.runtime.getValue48import androidx.compose.runtime.mutableStateOf49import androidx.compose.runtime.produceState50import androidx.compose.runtime.remember51import androidx.compose.runtime.setValue52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.clipToBounds56import androidx.compose.ui.draw.drawBehind57import androidx.compose.ui.geometry.Offset58import androidx.compose.ui.geometry.Size59import androidx.compose.ui.graphics.Brush60import androidx.compose.ui.graphics.Color61import androidx.compose.ui.graphics.SolidColor62import androidx.compose.ui.graphics.graphicsLayer63import androidx.compose.ui.platform.LocalContext64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.text.TextStyle66import androidx.compose.ui.text.font.FontStyle67import androidx.compose.ui.text.style.TextOverflow68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import androidx.compose.ui.unit.sp71import androidx.compose.runtime.collectAsState72import androidx.fragment.app.FragmentActivity73import androidx.hilt.navigation.compose.hiltViewModel74import com.peachpasswords.shared.detectHomograph75import com.peachpasswords.ui.PeachNav76import com.peachpasswords.ui.components.CharEnterText77import com.peachpasswords.ui.components.CopyButton78import com.peachpasswords.ui.components.FaviconInitials79import com.peachpasswords.ui.components.PeachEmptyOrEndState80import com.peachpasswords.ui.components.PeachTopBar81import com.peachpasswords.ui.components.SecureFlagEffect82import com.peachpasswords.ui.components.TOTPRing83import com.peachpasswords.ui.lock.Biometrics84import com.peachpasswords.ui.theme.LocalPeachAccent85import com.peachpasswords.ui.theme.LocalReduceMotion86import com.peachpasswords.ui.theme.PeachSpacing87import com.peachpasswords.ui.theme.peachColors88import com.peachpasswords.ui.theme.PeachType89import com.peachpasswords.ui.theme.ShellBackground90import com.peachpasswords.vault.TOTPGenerator91import com.peachpasswords.vault.hasTOTP92import com.peachpasswords.vault.hasPasskey93import com.peachpasswords.vault.isBreach94import com.peachpasswords.vault.isCard95import com.peachpasswords.vault.isNote96import com.peachpasswords.vault.isPasskeyOnly9798@Composable99fun EntryDetailScreen(100    id: String,101    onNavigate: (PeachNav) -> Unit,102    // Null when this entry is the post-unlock landing surface (C1: entry deck103    // is primary, not a child of the vault list) — there's genuinely nothing104    // to go "back" to, so the top chrome shows a lock affordance instead.105    onBack: (() -> Unit)?,106    vm: EntryDetailViewModel = hiltViewModel(),107) {108    val entry by vm.entry.collectAsState()109    val activeEntries by vm.activeEntries.collectAsState()110    val identitySuggestions by vm.identitySuggestions.collectAsState()111    val allTags by vm.allTags.collectAsState()112    val isPro by vm.isPro.collectAsState()113    val cvcRevealed by vm.cvcRevealed.collectAsState()114    var cardRevealed by remember { mutableStateOf(false) }115    val copiedField by vm.copiedField.collectAsState()116    val canaryMessage by vm.canaryMessage.collectAsState()117    val canaryTesting by vm.canaryTesting.collectAsState()118    val context = LocalContext.current119    val accent = LocalPeachAccent.current120    var confirmingDelete by remember { mutableStateOf(false) }121    var generatorOpen by remember { mutableStateOf(false) }122    var historyExpanded by remember { mutableStateOf(false) }123124    SecureFlagEffect()125126    val e = entry127    ShellBackground {128        Box(129            Modifier.fillMaxSize().statusBarsPadding()130                // C1: swipe left/right between deck entries, mirroring the131                // prev/next arrows in EntryDetailSearchStrip. Resolved on132                // drag end (not per-event) so an in-progress vertical scroll133                // gesture on the content below can't accidentally trigger it.134                .pointerInput(vm) {135                    var dragAccum = 0f136                    detectHorizontalDragGestures(137                        onDragStart = { dragAccum = 0f },138                        onDragEnd = {139                            when {140                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)141                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)142                            }143                            dragAccum = 0f144                        },145                        onDragCancel = { dragAccum = 0f },146                    ) { _, dragAmount -> dragAccum += dragAmount }147                },148        ) {149            Column(150                Modifier.fillMaxSize()151                    .padding(bottom = 100.dp)152                    .verticalScroll(rememberScrollState())153                    .padding(horizontal = PeachSpacing.xl),154            ) {155                EntryDetailTopBar(156                    entry = e,157                    confirmingDelete = confirmingDelete,158                    onBack = onBack,159                    onLock = vm::lock,160                    onDelete = {161                        if (confirmingDelete) {162                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })163                        } else {164                            confirmingDelete = true165                        }166                    },167                )168                Spacer(Modifier.height(PeachSpacing.lg))169170                if (e == null) {171                    PeachEmptyOrEndState(172                        message = "entry not found",173                        detail = "It may have been moved or deleted.",174                        modifier = Modifier.padding(top = PeachSpacing.xxl),175                    )176                    return@Column177                }178179                // D11 — breach banner strip when this entry's password is known-compromised.180                if (e.isBreach()) {181                    Row(182                        Modifier.fillMaxWidth()183                            .background(peachColors.BreachBg)184                            .padding(horizontal = 16.dp, vertical = 8.dp),185                        verticalAlignment = Alignment.CenterVertically,186                    ) {187                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)188                        Spacer(Modifier.width(10.dp))189                        Text(190                            "Password found in a data breach — change it now",191                            color = peachColors.Breach, style = PeachType.labelMono,192                            modifier = Modifier.weight(1f),193                        )194                        Text(195                            "Change password",196                            color = accent, style = PeachType.labelMono,197                            modifier = Modifier.clip(RoundedCornerShape(6.dp))198                                .clickable { generatorOpen = true }199                                .padding(horizontal = 6.dp, vertical = 4.dp),200                        )201                    }202                }203                if (entryHasHomographWarning(e)) {204                    HomographWarning()205                }206207                // Hero — ghost PeachScript watermark behind the entry name. The full208                // title is drawn (not just its first letter); a long title overflows209                // and clips naturally, which is the intended watermark behavior.210                Box(Modifier.fillMaxWidth().clipToBounds()) {211                    Text(212                        text = ghostWatermarkText(e.title),213                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),214                        color = peachColors.T0.copy(alpha = 0.03f),215                        maxLines = 1,216                        softWrap = false,217                        overflow = TextOverflow.Clip,218                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),219                    )220                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {221                        EditableTitleHero(222                            title = e.title,223                            onSave = { vm.updateField("title", it) },224                        )225                    }226                }227                Spacer(Modifier.height(PeachSpacing.lg))228229                if (e.isCard()) {230                    CardDetailFields(231                        entry = e,232                        cardRevealed = cardRevealed,233                        cvcRevealed = cvcRevealed,234                        copiedField = copiedField,235                        onToggleCardReveal = { cardRevealed = !cardRevealed },236                        onRevealCvc = {237                            val activity = context as? FragmentActivity238                            if (activity != null && Biometrics.isAvailable(context)) {239                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)240                            } else vm.revealCvc()241                        },242                        onMaskCvc = vm::maskCvc,243                        onCopy = vm::copy,244                        onSaveCard = vm::updateCard,245                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },246                    )247                } else {248                    if (!e.isNote()) {249                        EditableFieldRow(250                            label = "url", field = "url", value = e.url.orEmpty(),251                            copied = copiedField == "url",252                            onCopy = { vm.copy("url", e.url) },253                            onSave = { vm.updateField("url", it) },254                            placeholder = "add url",255                        )256                    }257                    e.username?.takeIf { it.isNotBlank() }?.let { v ->258                        EditableFieldRow(259                            label = "username", field = "username", value = v,260                            copied = copiedField == "username",261                            onCopy = { vm.copy("username", v) },262                            onSave = { vm.updateField("username", it) },263                            suggestions = identitySuggestions,264                        )265                    }266                    e.email?.takeIf { it.isNotBlank() }?.let { v ->267                        EditableFieldRow(268                            label = "email", field = "email", value = v,269                            copied = copiedField == "email",270                            onCopy = { vm.copy("email", v) },271                            onSave = { vm.updateField("email", it) },272                            suggestions = identitySuggestions,273                        )274                    }275                    AdditionalUrlRows(276                        urls = e.additionalUrls.orEmpty(),277                        copiedField = copiedField,278                        onCopy = { key, value -> vm.copy(key, value) },279                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },280                        onAdd = vm::addAdditionalUrl,281                    )282                    if (e.hasPasskey()) {283                        PasskeyDetailSection(e)284                    }285                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->286                        EditablePasswordRow(287                            value = pw,288                            copied = copiedField == "password",289                            entryTitle = e.title,290                            onCopy = { vm.copy("password", pw) },291                            onSave = { vm.updateField("password", it) },292                            onGenerate = { generatorOpen = true },293                        )294                    }295                    val history = e.passwordHistory.orEmpty()296                    if (history.isNotEmpty()) {297                        PasswordHistoryToggle(298                            count = history.size,299                            expanded = historyExpanded,300                            onToggle = { historyExpanded = !historyExpanded },301                        )302                        AnimatedVisibility(visible = historyExpanded) {303                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {304                                PasswordHistoryList(305                                    history = history,306                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },307                                    onCopy = { oldPw -> vm.copy("password", oldPw) },308                                )309                            }310                        }311                    }312                    if (e.hasTOTP()) {313                        Spacer(Modifier.height(4.dp))314                        TotpBandEditable(315                            totpField = e.totp!!,316                            copied = copiedField == "totp",317                            onCopy = vm::copyTotp,318                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },319                        )320                    }321                }322                e.notes?.takeIf { it.isNotBlank() }?.let { v ->323                    EditableFieldRow(324                        label = "notes", field = "notes", value = v,325                        copied = copiedField == "notes",326                        onCopy = { vm.copy("notes", v) },327                        onSave = { vm.updateField("notes", it) },328                        mono = false, singleLine = false,329                    )330                }331                DetailTagRow(332                    tags = e.tags.orEmpty(),333                    allTags = allTags,334                    onAddTag = vm::addTag,335                    onRemoveTag = vm::removeTag,336                )337                if (!e.isNote() && !e.isCard()) {338                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),339                    // unless a canary is already configured for this entry — every340                    // entry showing an "Off" canary block permanently was clutter341                    // most people never touch.342                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }343                    if (advancedExpanded) {344                        CanaryDetailSection(345                            entryId = e.id,346                            modifiedAt = e.modifiedAt,347                            canary = e.canary,348                            isPro = isPro,349                            message = canaryMessage,350                            testing = canaryTesting,351                            onSave = vm::saveCanary,352                            onTest = vm::testCanary,353                        )354                    } else {355                        Text(356                            "+ advanced",357                            color = LocalPeachAccent.current,358                            style = PeachType.labelMono,359                            modifier = Modifier360                                .clip(RoundedCornerShape(6.dp))361                                .clickable { advancedExpanded = true }362                                .padding(vertical = 6.dp),363                        )364                    }365                }366367                Spacer(Modifier.height(PeachSpacing.xxl))368            }369370            if (e != null) {371                EntryDetailSearchStrip(372                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },373                    totalEntries = activeEntries.size,374                    onOpenList = { onNavigate(PeachNav.VaultList) },375                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },376                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },377                    modifier = Modifier.align(Alignment.BottomCenter),378                )379            }380381            if (generatorOpen) {382                GeneratorSheet(383                    copied = copiedField == "genDraft",384                    onCopy = { draft -> vm.copy("genDraft", draft) },385                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },386                    onDismiss = { generatorOpen = false },387                )388            }389        }390    }391}392393@Composable394private fun EntryDetailTopBar(395    entry: com.peachpasswords.vault.VaultEntry?,396    confirmingDelete: Boolean,397    onBack: (() -> Unit)?,398    onLock: () -> Unit,399    onDelete: () -> Unit,400) {401    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }402    PeachTopBar(403        title = when {404            entry == null -> "entry"405            confirmingDelete -> "delete \"${entry.title}\"?"406            domain.isNotEmpty() -> domain407            else -> entry.title408        },409        leading = {410            if (onBack != null) {411                Icon(412                    Icons.AutoMirrored.Rounded.ArrowBack,413                    contentDescription = "back",414                    tint = peachColors.T0,415                    modifier = Modifier416                        .size(40.dp)417                        .clip(CircleShape)418                        .clickable(onClick = onBack)419                        .padding(PeachSpacing.sm),420                )421            } else {422                // This entry is the post-unlock landing surface, not pushed423                // from the vault list — there's nothing to go "back" to, so424                // the chrome offers the one action that actually makes sense425                // here instead (lock vault list's own affordance). Same426                // 40dp tap target as the back arrow, but the padlock glyph427                // reads visually heavier than the thin arrow chevron at the428                // same box size, so it gets a touch more inset to match.429                Icon(430                    Icons.Outlined.Lock,431                    contentDescription = "Lock vault",432                    tint = peachColors.T0,433                    modifier = Modifier434                        .size(40.dp)435                        .clip(CircleShape)436                        .clickable(onClick = onLock)437                        .padding(11.dp),438                )439            }440            if (entry != null && !confirmingDelete) {441                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)442            }443        },444        actions = {445            if (entry != null) {446                Icon(447                    Icons.Outlined.DeleteOutline,448                    contentDescription = "delete",449                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,450                    modifier = Modifier451                        .size(40.dp)452                        .clip(CircleShape)453                        .clickable(onClick = onDelete)454                        .padding(PeachSpacing.sm),455                )456            }457        },458    )459}460461@Composable462private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {463    val accent = LocalPeachAccent.current464    var editing by remember(title) { mutableStateOf(false) }465    var draft by remember(title) { mutableStateOf(title) }466    if (!editing) {467        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {468            CharEnterText(469                text = title,470                style = PeachType.entryNameHero.copy(color = peachColors.T0),471            )472        }473        return474    }475    Column(Modifier.fillMaxWidth()) {476        BasicTextField(477            value = draft,478            onValueChange = { draft = it },479            singleLine = true,480            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),481            cursorBrush = SolidColor(accent),482            modifier = Modifier.fillMaxWidth().drawBehind {483                drawLine(484                    color = accent,485                    start = Offset(0f, size.height),486                    end = Offset(size.width, size.height),487                    strokeWidth = 1.dp.toPx(),488                )489            },490        )491        Spacer(Modifier.height(8.dp))492        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {493            Text(494                "✓ save", color = accent, style = PeachType.labelMono,495                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {496                    onSave(draft)497                    editing = false498                }.padding(vertical = 4.dp),499            )500            Text(501                "✗", color = peachColors.T2, style = PeachType.labelMono,502                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {503                    draft = title504                    editing = false505                }.padding(horizontal = 6.dp, vertical = 4.dp),506            )507        }508    }509}510511@Composable512private fun HomographWarning() {513    Row(514        Modifier.fillMaxWidth()515            .padding(top = 8.dp)516            .clip(RoundedCornerShape(8.dp))517            .background(peachColors.BreachBg)518            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))519            .padding(horizontal = 12.dp, vertical = 9.dp),520        verticalAlignment = Alignment.CenterVertically,521    ) {522        Text("!", color = peachColors.Breach, style = PeachType.labelMono)523        Spacer(Modifier.width(10.dp))524        Text(525            "This URL contains non-ASCII or punycode characters.",526            color = peachColors.Breach,527            style = PeachType.labelMono,528            modifier = Modifier.weight(1f),529        )530    }531}532533@Composable534private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {535    val passkey = entry.passkey ?: return536    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {537        Column(Modifier.weight(1f)) {538            Text(539                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,540                color = peachColors.T0,541                style = PeachType.body,542            )543            Text(544                listOfNotNull(545                    passkey.rpId,546                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,547                ).joinToString(" · "),548                color = peachColors.T2,549                style = PeachType.labelMono,550            )551            Text(552                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",553                color = peachColors.T2,554                style = PeachType.labelMono,555                modifier = Modifier.padding(top = 3.dp),556            )557        }558    }559}560561@Composable562private fun CanaryDetailSection(563    entryId: String,564    modifiedAt: Long,565    canary: com.peachpasswords.vault.Canary?,566    isPro: Boolean,567    message: String?,568    testing: Boolean,569    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,570    onTest: (webhookUrl: String) -> Unit,571) {572    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }573    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }574    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }575    val gated = !isPro && canary == null576    FieldShell("canary alert") {577        Column(Modifier.weight(1f)) {578            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {579                Text(580                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",581                    color = peachColors.T0,582                    style = PeachType.body,583                    modifier = Modifier.weight(1f),584                )585                if (gated) {586                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)587                }588                Text(589                    if (enabled) "on" else "off",590                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,591                    style = PeachType.labelMono,592                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {593                        if (gated) {594                            onSave(true, webhookUrl, label)595                        } else {596                            val next = !enabled597                            enabled = next598                            if (!next) onSave(false, webhookUrl, label)599                        }600                    }.padding(horizontal = 6.dp, vertical = 4.dp),601                )602            }603            Text(604                "Fires a silent webhook when this entry's password or username is copied.",605                color = peachColors.T2,606                style = PeachType.labelMono,607                modifier = Modifier.padding(top = 3.dp),608            )609            if (enabled) {610                Spacer(Modifier.height(8.dp))611                PeachField(612                    label = "webhook url",613                    value = webhookUrl,614                    onChange = { webhookUrl = it },615                )616                Text(617                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",618                    color = peachColors.T2,619                    style = PeachType.labelMono,620                )621                PeachField(622                    label = "label",623                    value = label,624                    onChange = { label = it },625                    mono = false,626                )627                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {628                    Text(629                        "save",630                        color = LocalPeachAccent.current,631                        style = PeachType.labelMono,632                        modifier = Modifier.clip(RoundedCornerShape(6.dp))633                            .clickable { onSave(true, webhookUrl, label) }634                            .padding(vertical = 4.dp),635                    )636                    Text(637                        if (testing) "sending..." else "test ping",638                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,639                        style = PeachType.labelMono,640                        modifier = Modifier.clip(RoundedCornerShape(6.dp))641                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }642                            .padding(vertical = 4.dp),643                    )644                }645            }646            message?.let {647                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))648            }649            if (canary?.lastPinged != null || canary?.pingCount != null) {650                Text(651                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),652                    color = peachColors.T2,653                    style = PeachType.labelMono,654                    modifier = Modifier.padding(top = 3.dp),655                )656            }657        }658    }659}660661@Composable662private fun EntryDetailSearchStrip(663    currentIndex: Int,664    totalEntries: Int,665    onOpenList: () -> Unit,666    onPrevious: () -> Unit,667    onNext: () -> Unit,668    modifier: Modifier = Modifier,669) {670    val accent = LocalPeachAccent.current671    val hasEntries = totalEntries > 0 && currentIndex >= 0672    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"673674    Column(675        modifier676            .fillMaxWidth()677            .navigationBarsPadding()678            .padding(horizontal = 16.dp, vertical = 12.dp),679    ) {680        Row(681            Modifier682                .fillMaxWidth()683                .height(56.dp)684                .clip(RoundedCornerShape(16.dp))685                .background(peachColors.S1)686                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))687                .padding(horizontal = 12.dp),688            verticalAlignment = Alignment.CenterVertically,689        ) {690            Row(691                Modifier692                    .weight(1f)693                    .fillMaxHeight()694                    .clickable(onClick = onOpenList)695                    .pointerInput(onOpenList) {696                        var opened = false697                        detectVerticalDragGestures(698                            onDragEnd = { opened = false },699                            onDragCancel = { opened = false },700                        ) { _, dragAmount ->701                            if (!opened && dragAmount < -4f) {702                                opened = true703                                onOpenList()704                            }705                        }706                    },707                verticalAlignment = Alignment.CenterVertically,708            ) {709                Icon(710                    Icons.Rounded.Search,711                    contentDescription = null,712                    tint = accent,713                    modifier = Modifier.size(14.dp),714                )715                Spacer(Modifier.width(8.dp))716                Text(717                    "search vault...",718                    color = peachColors.T2,719                    style = PeachType.searchInput,720                    modifier = Modifier.weight(1f),721                )722                Text(723                    counter,724                    color = peachColors.T2,725                    style = PeachType.labelMono,726                )727            }728            Spacer(Modifier.width(8.dp))729            Icon(730                Icons.AutoMirrored.Rounded.ArrowBack,731                contentDescription = "Previous entry",732                tint = if (hasEntries) peachColors.T0 else peachColors.T2,733                modifier = Modifier734                    .size(38.dp)735                    .clip(CircleShape)736                    .clickable(enabled = hasEntries, onClick = onPrevious)737                    .padding(9.dp),738            )739            Icon(740                Icons.AutoMirrored.Rounded.ArrowForward,741                contentDescription = "Next entry",742                tint = if (hasEntries) peachColors.T0 else peachColors.T2,743                modifier = Modifier744                    .size(38.dp)745                    .clip(CircleShape)746                    .clickable(enabled = hasEntries, onClick = onNext)747                    .padding(9.dp),748            )749        }750    }751}752753/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */754@Composable755internal fun FieldShell(756    label: String,757    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,758) {759    val hairline = peachColors.T3760    Column(761        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {762            drawRect(763                color = hairline,764                topLeft = Offset(0f, size.height - 1.dp.toPx()),765                size = Size(size.width, 1.dp.toPx()),766            )767        },768    ) {769        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))770        Spacer(Modifier.height(6.dp))771        Row(verticalAlignment = Alignment.CenterVertically, content = content)772    }773}774775/**776 * Text for the ghost PeachScript watermark behind the entry name: the full title,777 * falling back to "P" for a blank title so the hero never renders empty.778 */779internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }780781internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =782    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)783784internal fun shortCredential(credentialId: String): String {785    if (credentialId.length <= 12) return credentialId786    return credentialId.take(6) + "..." + credentialId.takeLast(4)787}788789internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {790    val count = pingCount ?: 0791    val countLabel = "$count total alert" + if (count == 1) "" else "s"792    if (lastPinged == null) return countLabel793    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)794        .format(java.util.Date(lastPinged))795    return "Last triggered: $date · $countLabel"796}797798private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {799    val raw = url800    if (!raw.isNullOrBlank()) {801        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')802        if (cleaned.isNotEmpty()) return cleaned803    }804    return ""805}