EntryDetailScreen.kt

37.9 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.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.Add36import androidx.compose.material.icons.rounded.Check37import androidx.compose.material.icons.rounded.ContentCopy38import androidx.compose.material.icons.rounded.Search39import androidx.compose.material.icons.rounded.Settings40import androidx.compose.foundation.verticalScroll41import androidx.compose.material.icons.Icons42import androidx.compose.material.icons.automirrored.rounded.ArrowBack43import androidx.compose.material.icons.automirrored.rounded.ArrowForward44import androidx.compose.material.icons.outlined.Add45import androidx.compose.material.icons.outlined.Delete46import androidx.compose.material.icons.outlined.Description47import androidx.compose.material.icons.outlined.Settings48import androidx.compose.material.icons.outlined.Lock49import androidx.compose.material3.Icon50import androidx.compose.material3.Text51import androidx.compose.runtime.Composable52import androidx.compose.runtime.getValue53import androidx.compose.runtime.mutableStateOf54import androidx.compose.runtime.produceState55import androidx.compose.runtime.remember56import androidx.compose.runtime.setValue57import androidx.compose.ui.Alignment58import androidx.compose.ui.Modifier59import androidx.compose.ui.draw.clip60import androidx.compose.ui.draw.clipToBounds61import androidx.compose.ui.draw.drawBehind62import androidx.compose.ui.geometry.Offset63import androidx.compose.ui.geometry.Size64import androidx.compose.ui.graphics.Brush65import androidx.compose.ui.graphics.Color66import androidx.compose.ui.graphics.SolidColor67import androidx.compose.ui.graphics.graphicsLayer68import androidx.compose.ui.platform.LocalContext69import androidx.compose.ui.input.pointer.pointerInput70import androidx.compose.ui.text.TextStyle71import androidx.compose.ui.text.font.FontStyle72import androidx.compose.ui.text.style.TextOverflow73import androidx.compose.ui.unit.dp74import androidx.compose.ui.unit.em75import androidx.compose.ui.unit.sp76import androidx.compose.runtime.collectAsState77import androidx.fragment.app.FragmentActivity78import androidx.hilt.navigation.compose.hiltViewModel79import com.peachpasswords.shared.detectHomograph80import com.peachpasswords.ui.PeachNav81import com.peachpasswords.ui.components.CharEnterText82import com.peachpasswords.ui.components.CopyButton83import com.peachpasswords.ui.components.PeachEmptyOrEndState84import com.peachpasswords.ui.components.PeachMark85import com.peachpasswords.ui.components.PeachTopBar86import com.peachpasswords.ui.components.SecureFlagEffect87import com.peachpasswords.ui.components.TOTPRing88import com.peachpasswords.ui.lock.Biometrics89import com.peachpasswords.ui.theme.LocalPeachAccent90import com.peachpasswords.ui.theme.LocalReduceMotion91import com.peachpasswords.ui.theme.PeachSpacing92import com.peachpasswords.ui.theme.peachColors93import com.peachpasswords.ui.theme.PeachType94import com.peachpasswords.ui.theme.ShellBackground95import com.peachpasswords.vault.TOTPGenerator96import com.peachpasswords.vault.hasTOTP97import com.peachpasswords.vault.hasPasskey98import com.peachpasswords.vault.isBreach99import com.peachpasswords.vault.isCard100import com.peachpasswords.vault.isNote101import com.peachpasswords.vault.isPasskeyOnly102103@Composable104fun EntryDetailScreen(105    id: String,106    onNavigate: (PeachNav) -> Unit,107    // Null when this entry is the post-unlock landing surface (C1: entry deck108    // is primary, not a child of the vault list) — there's genuinely nothing109    // to go "back" to, so the top chrome shows a lock affordance instead.110    onBack: (() -> Unit)?,111    modifier: Modifier = Modifier,112    vm: EntryDetailViewModel = hiltViewModel(),113) {114    val entry by vm.entry.collectAsState()115    val activeEntries by vm.activeEntries.collectAsState()116    val identitySuggestions by vm.identitySuggestions.collectAsState()117    val allTags by vm.allTags.collectAsState()118    val isPro by vm.isPro.collectAsState()119    val cvcRevealed by vm.cvcRevealed.collectAsState()120    var cardRevealed by remember { mutableStateOf(false) }121    val copiedField by vm.copiedField.collectAsState()122    val canaryMessage by vm.canaryMessage.collectAsState()123    val canaryTesting by vm.canaryTesting.collectAsState()124    val context = LocalContext.current125    val accent = LocalPeachAccent.current126    var confirmingDelete by remember { mutableStateOf(false) }127    var generatorOpen by remember { mutableStateOf(false) }128    var historyExpanded by remember { mutableStateOf(false) }129130    SecureFlagEffect()131132    val e = entry133    ShellBackground(modifier) {134        Box(135            Modifier.fillMaxSize().statusBarsPadding()136                // C1: swipe left/right between deck entries, mirroring the137                // prev/next arrows in EntryDetailSearchStrip. Resolved on138                // drag end (not per-event) so an in-progress vertical scroll139                // gesture on the content below can't accidentally trigger it.140                .pointerInput(vm) {141                    var dragAccum = 0f142                    detectHorizontalDragGestures(143                        onDragStart = { dragAccum = 0f },144                        onDragEnd = {145                            when {146                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)147                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)148                            }149                            dragAccum = 0f150                        },151                        onDragCancel = { dragAccum = 0f },152                    ) { _, dragAmount -> dragAccum += dragAmount }153                },154        ) {155            Column(156                Modifier.fillMaxSize()157                    .padding(bottom = 100.dp)158                    .verticalScroll(rememberScrollState())159                    .padding(horizontal = PeachSpacing.xl),160            ) {161                EntryDetailTopBar(162                    entry = e,163                    confirmingDelete = confirmingDelete,164                    onBack = onBack,165                    onLock = vm::lock,166                    onDelete = {167                        if (confirmingDelete) {168                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })169                        } else {170                            confirmingDelete = true171                        }172                    },173                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },174                    onSettings = { onNavigate(PeachNav.Settings) },175                    onOpenNotes = { onNavigate(PeachNav.Notes) },176                )177                Spacer(Modifier.height(PeachSpacing.lg))178179                if (e == null) {180                    PeachEmptyOrEndState(181                        message = "entry not found",182                        detail = "It may have been moved or deleted.",183                        modifier = Modifier.padding(top = PeachSpacing.xxl),184                    )185                    return@Column186                }187188                // D11 — breach banner strip when this entry's password is known-compromised.189                if (e.isBreach()) {190                    Row(191                        Modifier.fillMaxWidth()192                            .background(peachColors.BreachBg)193                            .padding(horizontal = 16.dp, vertical = 8.dp),194                        verticalAlignment = Alignment.CenterVertically,195                    ) {196                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)197                        Spacer(Modifier.width(10.dp))198                        Text(199                            "Password found in a data breach — change it now",200                            color = peachColors.Breach, style = PeachType.labelMono,201                            modifier = Modifier.weight(1f),202                        )203                        Text(204                            "Change password",205                            color = accent, style = PeachType.labelMono,206                            modifier = Modifier.clip(RoundedCornerShape(6.dp))207                                .clickable { generatorOpen = true }208                                .padding(horizontal = 6.dp, vertical = 4.dp),209                        )210                    }211                }212                if (entryHasHomographWarning(e)) {213                    HomographWarning()214                }215216                // Hero — ghost PeachScript watermark behind the entry name. The full217                // title is drawn (not just its first letter); a long title overflows218                // and clips naturally, which is the intended watermark behavior.219                Box(Modifier.fillMaxWidth().clipToBounds()) {220                    Text(221                        text = ghostWatermarkText(e.title),222                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),223                        color = peachColors.T0.copy(alpha = 0.03f),224                        maxLines = 1,225                        softWrap = false,226                        overflow = TextOverflow.Clip,227                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),228                    )229                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {230                        EditableTitleHero(231                            title = e.title,232                            onSave = { vm.updateField("title", it) },233                        )234                    }235                }236                Spacer(Modifier.height(PeachSpacing.lg))237238                if (e.isCard()) {239                    CardDetailFields(240                        entry = e,241                        cardRevealed = cardRevealed,242                        cvcRevealed = cvcRevealed,243                        copiedField = copiedField,244                        onToggleCardReveal = { cardRevealed = !cardRevealed },245                        onRevealCvc = {246                            val activity = context as? FragmentActivity247                            if (activity != null && Biometrics.isAvailable(context)) {248                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)249                            } else vm.revealCvc()250                        },251                        onMaskCvc = vm::maskCvc,252                        onCopy = vm::copy,253                        onSaveCard = vm::updateCard,254                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },255                    )256                } else {257                    if (!e.isNote()) {258                        EditableFieldRow(259                            label = "url", field = "url", value = e.url.orEmpty(),260                            copied = copiedField == "url",261                            onCopy = { vm.copy("url", e.url) },262                            onSave = { vm.updateField("url", it) },263                            placeholder = "add url",264                        )265                    }266                    // Browser distinguishes USERNAME and EMAIL as separate267                    // optional fields, with a "+ add X" link for whichever the268                    // entry doesn't have yet — an entry can carry both. This269                    // used to just omit the row entirely for a blank field,270                    // with no way to add it from the detail screen.271                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }272                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }273                    if (showUsernameField) {274                        EditableFieldRow(275                            label = "username", field = "username", value = e.username.orEmpty(),276                            copied = copiedField == "username",277                            onCopy = { vm.copy("username", e.username) },278                            onSave = { vm.updateField("username", it) },279                            suggestions = identitySuggestions,280                            placeholder = "add username",281                        )282                    }283                    if (showEmailField) {284                        EditableFieldRow(285                            label = "email", field = "email", value = e.email.orEmpty(),286                            copied = copiedField == "email",287                            onCopy = { vm.copy("email", e.email) },288                            onSave = { vm.updateField("email", it) },289                            suggestions = identitySuggestions,290                            placeholder = "add email",291                        )292                    }293                    if (!showUsernameField || !showEmailField) {294                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {295                            if (!showUsernameField) {296                                Text(297                                    "+ add username", color = accent, style = PeachType.labelMono,298                                    modifier = Modifier299                                        .clip(RoundedCornerShape(6.dp))300                                        .clickable { showUsernameField = true }301                                        .padding(vertical = 6.dp),302                                )303                            }304                            if (!showEmailField) {305                                Text(306                                    "+ add email", color = accent, style = PeachType.labelMono,307                                    modifier = Modifier308                                        .clip(RoundedCornerShape(6.dp))309                                        .clickable { showEmailField = true }310                                        .padding(vertical = 6.dp),311                                )312                            }313                        }314                    }315                    AdditionalUrlRows(316                        urls = e.additionalUrls.orEmpty(),317                        copiedField = copiedField,318                        onCopy = { key, value -> vm.copy(key, value) },319                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },320                        onAdd = vm::addAdditionalUrl,321                    )322                    if (e.hasPasskey()) {323                        PasskeyDetailSection(e)324                    }325                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->326                        EditablePasswordRow(327                            value = pw,328                            copied = copiedField == "password",329                            entryTitle = e.title,330                            onCopy = { vm.copy("password", pw) },331                            onSave = { vm.updateField("password", it) },332                            onGenerate = { generatorOpen = true },333                        )334                    }335                    val history = e.passwordHistory.orEmpty()336                    if (history.isNotEmpty()) {337                        PasswordHistoryToggle(338                            count = history.size,339                            expanded = historyExpanded,340                            onToggle = { historyExpanded = !historyExpanded },341                        )342                        AnimatedVisibility(visible = historyExpanded) {343                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {344                                PasswordHistoryList(345                                    history = history,346                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },347                                    onCopy = { oldPw -> vm.copy("password", oldPw) },348                                )349                            }350                        }351                    }352                    if (e.hasTOTP()) {353                        Spacer(Modifier.height(4.dp))354                        TotpBandEditable(355                            totpField = e.totp!!,356                            copied = copiedField == "totp",357                            onCopy = vm::copyTotp,358                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },359                        )360                    }361                }362                e.notes?.takeIf { it.isNotBlank() }?.let { v ->363                    EditableFieldRow(364                        label = "notes", field = "notes", value = v,365                        copied = copiedField == "notes",366                        onCopy = { vm.copy("notes", v) },367                        onSave = { vm.updateField("notes", it) },368                        mono = false, singleLine = false,369                    )370                }371                DetailTagRow(372                    tags = e.tags.orEmpty(),373                    allTags = allTags,374                    onAddTag = vm::addTag,375                    onRemoveTag = vm::removeTag,376                )377                if (!e.isNote() && !e.isCard()) {378                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),379                    // unless a canary is already configured for this entry — every380                    // entry showing an "Off" canary block permanently was clutter381                    // most people never touch.382                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }383                    if (advancedExpanded) {384                        CanaryDetailSection(385                            entryId = e.id,386                            modifiedAt = e.modifiedAt,387                            canary = e.canary,388                            isPro = isPro,389                            message = canaryMessage,390                            testing = canaryTesting,391                            onSave = vm::saveCanary,392                            onTest = vm::testCanary,393                        )394                    } else {395                        Text(396                            "+ advanced",397                            color = LocalPeachAccent.current,398                            style = PeachType.labelMono,399                            modifier = Modifier400                                .clip(RoundedCornerShape(6.dp))401                                .clickable { advancedExpanded = true }402                                .padding(vertical = 6.dp),403                        )404                    }405                }406407                Spacer(Modifier.height(PeachSpacing.xxl))408            }409410            if (e != null) {411                EntryDetailSearchStrip(412                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },413                    totalEntries = activeEntries.size,414                    onOpenList = { onNavigate(PeachNav.VaultList) },415                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },416                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },417                    modifier = Modifier.align(Alignment.BottomCenter),418                )419            }420421            if (generatorOpen) {422                GeneratorSheet(423                    copied = copiedField == "genDraft",424                    onCopy = { draft -> vm.copy("genDraft", draft) },425                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },426                    onDismiss = { generatorOpen = false },427                )428            }429        }430    }431}432433@Composable434private fun EntryDetailTopBar(435    entry: com.peachpasswords.vault.VaultEntry?,436    confirmingDelete: Boolean,437    onBack: (() -> Unit)?,438    onLock: () -> Unit,439    onDelete: () -> Unit,440    onAddEntry: () -> Unit,441    onSettings: () -> Unit,442    onOpenNotes: () -> Unit,443) {444    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }445    PeachTopBar(446        title = when {447            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"448            // Deck root (no back target) mirrors the browser's persistent449            // header, which always shows the "peach" wordmark — never the450            // current entry's identity — leaving PeachMark in `leading` below451            // to complete the wordmark pairing (see LockScreen's same pattern).452            onBack == null -> "peach"453            entry == null -> "entry"454            domain.isNotEmpty() -> domain455            else -> entry.title456        },457        leading = {458            if (onBack != null) {459                Icon(460                    Icons.AutoMirrored.Rounded.ArrowBack,461                    contentDescription = "back",462                    tint = peachColors.T1,463                    modifier = Modifier464                        .size(40.dp)465                        .clip(CircleShape)466                        .clickable(onClick = onBack)467                        .padding(PeachSpacing.sm),468                )469            } else {470                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))471            }472            // The favicon chip used to sit here, but it's the one loud,473            // colorful element in an otherwise quiet/muted bar (matching the474            // browser's persistent header, which carries no per-entry color475            // at all) — and it's redundant with the entry's own hero section476            // just below, which already shows its identity clearly.477        },478        actions = {479            // The chrome actions render on EVERY entry, not just the deck root.480            // They used to be gated on `onBack == null`, so an entry opened from481            // the vault list showed a bare back-arrow-and-title bar while the482            // deck root showed the full set — the same surface with two483            // different bars, which reads as the toolbar breaking mid-swipe.484            // The browser's header is persistent; this now is too. Order and485            // family mirror it: Notes, Settings, New entry, Delete, Lock, all486            // Outlined at one optical size.487            ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)488            ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)489            ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)490            if (entry != null) {491                ChromeAction(492                    Icons.Outlined.Delete,493                    "delete",494                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,495                    onClick = onDelete,496                )497            }498            ChromeAction(Icons.Outlined.Lock, "Lock vault", onClick = onLock)499        },500    )501}502503/** One shape for every top-bar action so the cluster reads as a set. */504@Composable505private fun ChromeAction(506    icon: androidx.compose.ui.graphics.vector.ImageVector,507    label: String,508    tint: Color = peachColors.T1,509    onClick: () -> Unit,510) {511    Icon(512        icon,513        contentDescription = label,514        tint = tint,515        modifier = Modifier516            .size(38.dp)517            .clip(CircleShape)518            .clickable(onClick = onClick)519            .padding(9.dp),520    )521}522523@Composable524private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {525    val accent = LocalPeachAccent.current526    var editing by remember(title) { mutableStateOf(false) }527    var draft by remember(title) { mutableStateOf(title) }528    if (!editing) {529        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {530            CharEnterText(531                text = title,532                style = PeachType.entryNameHero.copy(color = peachColors.T0),533            )534        }535        return536    }537    Column(Modifier.fillMaxWidth()) {538        BasicTextField(539            value = draft,540            onValueChange = { draft = it },541            singleLine = true,542            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),543            cursorBrush = SolidColor(accent),544            modifier = Modifier.fillMaxWidth().drawBehind {545                drawLine(546                    color = accent,547                    start = Offset(0f, size.height),548                    end = Offset(size.width, size.height),549                    strokeWidth = 1.dp.toPx(),550                )551            },552        )553        Spacer(Modifier.height(8.dp))554        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {555            Text(556                "✓ save", color = accent, style = PeachType.labelMono,557                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {558                    onSave(draft)559                    editing = false560                }.padding(vertical = 4.dp),561            )562            Text(563                "✗", color = peachColors.T2, style = PeachType.labelMono,564                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {565                    draft = title566                    editing = false567                }.padding(horizontal = 6.dp, vertical = 4.dp),568            )569        }570    }571}572573@Composable574private fun HomographWarning() {575    Row(576        Modifier.fillMaxWidth()577            .padding(top = 8.dp)578            .clip(RoundedCornerShape(8.dp))579            .background(peachColors.BreachBg)580            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))581            .padding(horizontal = 12.dp, vertical = 9.dp),582        verticalAlignment = Alignment.CenterVertically,583    ) {584        Text("!", color = peachColors.Breach, style = PeachType.labelMono)585        Spacer(Modifier.width(10.dp))586        Text(587            "This URL contains non-ASCII or punycode characters.",588            color = peachColors.Breach,589            style = PeachType.labelMono,590            modifier = Modifier.weight(1f),591        )592    }593}594595@Composable596private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {597    val passkey = entry.passkey ?: return598    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {599        Column(Modifier.weight(1f)) {600            Text(601                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,602                color = peachColors.T0,603                style = PeachType.body,604            )605            Text(606                listOfNotNull(607                    passkey.rpId,608                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,609                ).joinToString(" · "),610                color = peachColors.T2,611                style = PeachType.labelMono,612            )613            Text(614                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",615                color = peachColors.T2,616                style = PeachType.labelMono,617                modifier = Modifier.padding(top = 3.dp),618            )619        }620    }621}622623@Composable624private fun CanaryDetailSection(625    entryId: String,626    modifiedAt: Long,627    canary: com.peachpasswords.vault.Canary?,628    isPro: Boolean,629    message: String?,630    testing: Boolean,631    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,632    onTest: (webhookUrl: String) -> Unit,633) {634    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }635    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }636    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }637    val gated = !isPro && canary == null638    FieldShell("canary alert") {639        Column(Modifier.weight(1f)) {640            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {641                Text(642                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",643                    color = peachColors.T0,644                    style = PeachType.body,645                    modifier = Modifier.weight(1f),646                )647                if (gated) {648                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)649                }650                Text(651                    if (enabled) "on" else "off",652                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,653                    style = PeachType.labelMono,654                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {655                        if (gated) {656                            onSave(true, webhookUrl, label)657                        } else {658                            val next = !enabled659                            enabled = next660                            if (!next) onSave(false, webhookUrl, label)661                        }662                    }.padding(horizontal = 6.dp, vertical = 4.dp),663                )664            }665            Text(666                "Fires a silent webhook when this entry's password or username is copied.",667                color = peachColors.T2,668                style = PeachType.labelMono,669                modifier = Modifier.padding(top = 3.dp),670            )671            if (enabled) {672                Spacer(Modifier.height(8.dp))673                PeachField(674                    label = "webhook url",675                    value = webhookUrl,676                    onChange = { webhookUrl = it },677                )678                Text(679                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",680                    color = peachColors.T2,681                    style = PeachType.labelMono,682                )683                PeachField(684                    label = "label",685                    value = label,686                    onChange = { label = it },687                    mono = false,688                )689                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {690                    Text(691                        "save",692                        color = LocalPeachAccent.current,693                        style = PeachType.labelMono,694                        modifier = Modifier.clip(RoundedCornerShape(6.dp))695                            .clickable { onSave(true, webhookUrl, label) }696                            .padding(vertical = 4.dp),697                    )698                    Text(699                        if (testing) "sending..." else "test ping",700                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,701                        style = PeachType.labelMono,702                        modifier = Modifier.clip(RoundedCornerShape(6.dp))703                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }704                            .padding(vertical = 4.dp),705                    )706                }707            }708            message?.let {709                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))710            }711            if (canary?.lastPinged != null || canary?.pingCount != null) {712                Text(713                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),714                    color = peachColors.T2,715                    style = PeachType.labelMono,716                    modifier = Modifier.padding(top = 3.dp),717                )718            }719        }720    }721}722723@Composable724private fun EntryDetailSearchStrip(725    currentIndex: Int,726    totalEntries: Int,727    onOpenList: () -> Unit,728    onPrevious: () -> Unit,729    onNext: () -> Unit,730    modifier: Modifier = Modifier,731) {732    val accent = LocalPeachAccent.current733    val hasEntries = totalEntries > 0 && currentIndex >= 0734    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"735736    Column(737        modifier738            .fillMaxWidth()739            .navigationBarsPadding()740            .padding(horizontal = 16.dp, vertical = 12.dp),741    ) {742        Row(743            Modifier744                .fillMaxWidth()745                .height(56.dp)746                .clip(RoundedCornerShape(16.dp))747                .background(peachColors.S1)748                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))749                .padding(horizontal = 12.dp),750            verticalAlignment = Alignment.CenterVertically,751        ) {752            Row(753                Modifier754                    .weight(1f)755                    .fillMaxHeight()756                    .clickable(onClick = onOpenList)757                    .pointerInput(onOpenList) {758                        var opened = false759                        detectVerticalDragGestures(760                            onDragEnd = { opened = false },761                            onDragCancel = { opened = false },762                        ) { _, dragAmount ->763                            if (!opened && dragAmount < -4f) {764                                opened = true765                                onOpenList()766                            }767                        }768                    },769                verticalAlignment = Alignment.CenterVertically,770            ) {771                Icon(772                    Icons.Rounded.Search,773                    contentDescription = null,774                    tint = accent,775                    modifier = Modifier.size(14.dp),776                )777                Spacer(Modifier.width(8.dp))778                Text(779                    "search vault...",780                    color = peachColors.T2,781                    style = PeachType.searchInput,782                    modifier = Modifier.weight(1f),783                )784                Text(785                    counter,786                    color = peachColors.T2,787                    style = PeachType.labelMono,788                )789            }790            Spacer(Modifier.width(8.dp))791            Icon(792                Icons.AutoMirrored.Rounded.ArrowBack,793                contentDescription = "Previous entry",794                tint = if (hasEntries) peachColors.T0 else peachColors.T2,795                modifier = Modifier796                    .size(38.dp)797                    .clip(CircleShape)798                    .clickable(enabled = hasEntries, onClick = onPrevious)799                    .padding(9.dp),800            )801            Icon(802                Icons.AutoMirrored.Rounded.ArrowForward,803                contentDescription = "Next entry",804                tint = if (hasEntries) peachColors.T0 else peachColors.T2,805                modifier = Modifier806                    .size(38.dp)807                    .clip(CircleShape)808                    .clickable(enabled = hasEntries, onClick = onNext)809                    .padding(9.dp),810            )811        }812    }813}814815/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */816@Composable817internal fun FieldShell(818    label: String,819    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,820) {821    val hairline = peachColors.T3822    Column(823        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {824            drawRect(825                color = hairline,826                topLeft = Offset(0f, size.height - 1.dp.toPx()),827                size = Size(size.width, 1.dp.toPx()),828            )829        },830    ) {831        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))832        Spacer(Modifier.height(6.dp))833        Row(verticalAlignment = Alignment.CenterVertically, content = content)834    }835}836837/**838 * Text for the ghost PeachScript watermark behind the entry name: the full title,839 * falling back to "P" for a blank title so the hero never renders empty.840 */841internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }842843internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =844    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)845846internal fun shortCredential(credentialId: String): String {847    if (credentialId.length <= 12) return credentialId848    return credentialId.take(6) + "..." + credentialId.takeLast(4)849}850851internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {852    val count = pingCount ?: 0853    val countLabel = "$count total alert" + if (count == 1) "" else "s"854    if (lastPinged == null) return countLabel855    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)856        .format(java.util.Date(lastPinged))857    return "Last triggered: $date · $countLabel"858}859860private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {861    val raw = url862    if (!raw.isNullOrBlank()) {863        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')864        if (cleaned.isNotEmpty()) return cleaned865    }866    return ""867}