EntryDetailScreen.kt

39.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.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.TOTPRing87import com.peachpasswords.ui.lock.Biometrics88import com.peachpasswords.ui.theme.LocalPeachAccent89import com.peachpasswords.ui.theme.LocalReduceMotion90import com.peachpasswords.ui.theme.PeachSpacing91import com.peachpasswords.ui.theme.peachColors92import com.peachpasswords.ui.theme.PeachType93import com.peachpasswords.ui.theme.ShellBackground94import com.peachpasswords.vault.TOTPGenerator95import com.peachpasswords.vault.hasTOTP96import com.peachpasswords.vault.hasPasskey97import com.peachpasswords.vault.isBreach98import com.peachpasswords.vault.isCard99import com.peachpasswords.vault.isNote100import com.peachpasswords.vault.isPasskeyOnly101102@Composable103fun EntryDetailScreen(104    id: String,105    onNavigate: (PeachNav) -> Unit,106    // Null when this entry is the post-unlock landing surface (C1: entry deck107    // is primary, not a child of the vault list) — there's genuinely nothing108    // to go "back" to, so the top chrome shows a lock affordance instead.109    onBack: (() -> Unit)?,110    modifier: Modifier = Modifier,111    vm: EntryDetailViewModel = hiltViewModel(),112) {113    val entry by vm.entry.collectAsState()114    val activeEntries by vm.activeEntries.collectAsState()115    val identitySuggestions by vm.identitySuggestions.collectAsState()116    val allTags by vm.allTags.collectAsState()117    val isPro by vm.isPro.collectAsState()118    val cvcRevealed by vm.cvcRevealed.collectAsState()119    var cardRevealed by remember { mutableStateOf(false) }120    val copiedField by vm.copiedField.collectAsState()121    val canaryMessage by vm.canaryMessage.collectAsState()122    val canaryTesting by vm.canaryTesting.collectAsState()123    val context = LocalContext.current124    val accent = LocalPeachAccent.current125    var confirmingDelete by remember { mutableStateOf(false) }126    var generatorOpen by remember { mutableStateOf(false) }127    var historyExpanded by remember { mutableStateOf(false) }128129    val e = entry130    ShellBackground(modifier) {131        Box(132            Modifier.fillMaxSize().statusBarsPadding()133                // C1: swipe left/right between deck entries, mirroring the134                // prev/next arrows in EntryDetailSearchStrip. Resolved on135                // drag end (not per-event) so an in-progress vertical scroll136                // gesture on the content below can't accidentally trigger it.137                .pointerInput(vm) {138                    var dragAccum = 0f139                    detectHorizontalDragGestures(140                        onDragStart = { dragAccum = 0f },141                        onDragEnd = {142                            when {143                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)144                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)145                            }146                            dragAccum = 0f147                        },148                        onDragCancel = { dragAccum = 0f },149                    ) { _, dragAmount -> dragAccum += dragAmount }150                },151        ) {152            Column(153                Modifier.fillMaxSize()154                    .padding(bottom = 100.dp)155                    .verticalScroll(rememberScrollState())156                    .padding(horizontal = PeachSpacing.xl),157            ) {158                EntryDetailTopBar(159                    entry = e,160                    confirmingDelete = confirmingDelete,161                    onBack = onBack,162                    onLock = vm::lock,163                    onDelete = {164                        if (confirmingDelete) {165                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })166                        } else {167                            confirmingDelete = true168                        }169                    },170                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },171                    onSettings = { onNavigate(PeachNav.Settings) },172                    onOpenNotes = { onNavigate(PeachNav.Notes) },173                )174                Spacer(Modifier.height(PeachSpacing.lg))175176                if (e == null) {177                    PeachEmptyOrEndState(178                        message = "entry not found",179                        detail = "It may have been moved or deleted.",180                        modifier = Modifier.padding(top = PeachSpacing.xxl),181                    )182                    return@Column183                }184185                // D11 — breach banner strip when this entry's password is known-compromised.186                if (e.isBreach()) {187                    Row(188                        Modifier.fillMaxWidth()189                            .background(peachColors.BreachBg)190                            .padding(horizontal = 16.dp, vertical = 8.dp),191                        verticalAlignment = Alignment.CenterVertically,192                    ) {193                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)194                        Spacer(Modifier.width(10.dp))195                        Text(196                            "Password found in a data breach — change it now",197                            color = peachColors.Breach, style = PeachType.labelMono,198                            modifier = Modifier.weight(1f),199                        )200                        Text(201                            "Change password",202                            color = accent, style = PeachType.labelMono,203                            modifier = Modifier.clip(RoundedCornerShape(6.dp))204                                .clickable { generatorOpen = true }205                                .padding(horizontal = 6.dp, vertical = 4.dp),206                        )207                    }208                }209                if (entryHasHomographWarning(e)) {210                    HomographWarning()211                }212213                // Hero — ghost PeachScript watermark behind the entry name. The full214                // title is drawn (not just its first letter); a long title overflows215                // and clips naturally, which is the intended watermark behavior.216                Box(Modifier.fillMaxWidth().clipToBounds()) {217                    Text(218                        text = ghostWatermarkText(e.title),219                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),220                        color = peachColors.T0.copy(alpha = 0.03f),221                        maxLines = 1,222                        softWrap = false,223                        overflow = TextOverflow.Clip,224                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),225                    )226                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {227                        EditableTitleHero(228                            title = e.title,229                            onSave = { vm.updateField("title", it) },230                        )231                    }232                }233                Spacer(Modifier.height(PeachSpacing.lg))234235                if (e.isCard()) {236                    CardDetailFields(237                        entry = e,238                        cardRevealed = cardRevealed,239                        cvcRevealed = cvcRevealed,240                        copiedField = copiedField,241                        onToggleCardReveal = { cardRevealed = !cardRevealed },242                        onRevealCvc = {243                            val activity = context as? FragmentActivity244                            if (activity != null && Biometrics.isAvailable(context)) {245                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)246                            } else vm.revealCvc()247                        },248                        onMaskCvc = vm::maskCvc,249                        onCopy = vm::copy,250                        onSaveCard = vm::updateCard,251                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },252                    )253                } else {254                    if (!e.isNote()) {255                        EditableFieldRow(256                            label = "url", field = "url", value = e.url.orEmpty(),257                            copied = copiedField == "url",258                            onCopy = { vm.copy("url", e.url) },259                            onSave = { vm.updateField("url", it) },260                            placeholder = "add url",261                        )262                    }263                    // Browser distinguishes USERNAME and EMAIL as separate264                    // optional fields, with a "+ add X" link for whichever the265                    // entry doesn't have yet — an entry can carry both. This266                    // used to just omit the row entirely for a blank field,267                    // with no way to add it from the detail screen.268                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }269                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }270                    if (showUsernameField) {271                        EditableFieldRow(272                            label = "username", field = "username", value = e.username.orEmpty(),273                            copied = copiedField == "username",274                            onCopy = { vm.copy("username", e.username) },275                            onSave = { vm.updateField("username", it) },276                            suggestions = identitySuggestions,277                            placeholder = "add username",278                        )279                    }280                    if (showEmailField) {281                        EditableFieldRow(282                            label = "email", field = "email", value = e.email.orEmpty(),283                            copied = copiedField == "email",284                            onCopy = { vm.copy("email", e.email) },285                            onSave = { vm.updateField("email", it) },286                            suggestions = identitySuggestions,287                            placeholder = "add email",288                        )289                    }290                    if (!showUsernameField || !showEmailField) {291                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {292                            if (!showUsernameField) {293                                Text(294                                    "+ add username", color = accent, style = PeachType.labelMono,295                                    modifier = Modifier296                                        .clip(RoundedCornerShape(6.dp))297                                        .clickable { showUsernameField = true }298                                        .padding(vertical = 6.dp),299                                )300                            }301                            if (!showEmailField) {302                                Text(303                                    "+ add email", color = accent, style = PeachType.labelMono,304                                    modifier = Modifier305                                        .clip(RoundedCornerShape(6.dp))306                                        .clickable { showEmailField = true }307                                        .padding(vertical = 6.dp),308                                )309                            }310                        }311                    }312                    AdditionalUrlRows(313                        urls = e.additionalUrls.orEmpty(),314                        copiedField = copiedField,315                        onCopy = { key, value -> vm.copy(key, value) },316                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },317                        onAdd = vm::addAdditionalUrl,318                    )319                    if (e.hasPasskey()) {320                        PasskeyDetailSection(e)321                    }322                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->323                        EditablePasswordRow(324                            value = pw,325                            copied = copiedField == "password",326                            entryTitle = e.title,327                            onCopy = { vm.copy("password", pw) },328                            onSave = { vm.updateField("password", it) },329                            onGenerate = { generatorOpen = true },330                        )331                    }332                    // Always show the history affordance (browser parity) — visible333                    // even before any password change, expanding to an empty-state334                    // explainer so the feature is discoverable rather than hidden335                    // until history already exists.336                    val history = e.passwordHistory.orEmpty()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                    if (e.hasTOTP()) {352                        Spacer(Modifier.height(4.dp))353                        TotpBandEditable(354                            totpField = e.totp!!,355                            copied = copiedField == "totp",356                            onCopy = vm::copyTotp,357                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },358                        )359                    }360                }361                e.notes?.takeIf { it.isNotBlank() }?.let { v ->362                    // A note authored in the browser is HTML: shown rendered,363                    // edited as markup, saved back as the same tags.364                    val notesAreHtml = isNoteHtml(v)365                    EditableFieldRow(366                        label = "notes", field = "notes", value = v,367                        copied = copiedField == "notes",368                        onCopy = { vm.copy("notes", notePlainText(v)) },369                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },370                        mono = false, singleLine = false,371                        displayText = noteRendered(v),372                        draftText = noteEditorText(v),373                    )374                }375                DetailTagRow(376                    tags = e.tags.orEmpty(),377                    allTags = allTags,378                    onAddTag = vm::addTag,379                    onRemoveTag = vm::removeTag,380                )381                if (!e.isNote() && !e.isCard()) {382                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),383                    // unless a canary is already configured for this entry — every384                    // entry showing an "Off" canary block permanently was clutter385                    // most people never touch.386                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }387                    if (advancedExpanded) {388                        CanaryDetailSection(389                            entryId = e.id,390                            modifiedAt = e.modifiedAt,391                            canary = e.canary,392                            isPro = isPro,393                            message = canaryMessage,394                            testing = canaryTesting,395                            onSave = vm::saveCanary,396                            onTest = vm::testCanary,397                        )398                    } else {399                        Text(400                            "+ advanced",401                            color = LocalPeachAccent.current,402                            style = PeachType.labelMono,403                            modifier = Modifier404                                .clip(RoundedCornerShape(6.dp))405                                .clickable { advancedExpanded = true }406                                .padding(vertical = 6.dp),407                        )408                    }409                }410411                Spacer(Modifier.height(PeachSpacing.xxl))412            }413414            if (e != null) {415                EntryDetailSearchStrip(416                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },417                    totalEntries = activeEntries.size,418                    onOpenList = { onNavigate(PeachNav.VaultList) },419                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },420                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },421                    modifier = Modifier.align(Alignment.BottomCenter),422                )423            }424425            if (generatorOpen) {426                GeneratorSheet(427                    copied = copiedField == "genDraft",428                    onCopy = { draft -> vm.copy("genDraft", draft) },429                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },430                    onDismiss = { generatorOpen = false },431                )432            }433        }434    }435}436437@Composable438private fun EntryDetailTopBar(439    entry: com.peachpasswords.vault.VaultEntry?,440    confirmingDelete: Boolean,441    onBack: (() -> Unit)?,442    onLock: () -> Unit,443    onDelete: () -> Unit,444    onAddEntry: () -> Unit,445    onSettings: () -> Unit,446    onOpenNotes: () -> Unit,447) {448    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }449    PeachTopBar(450        title = when {451            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"452            // Deck root (no back target) mirrors the browser's persistent453            // header, which always shows the "peach" wordmark — never the454            // current entry's identity — leaving PeachMark in `leading` below455            // to complete the wordmark pairing (see LockScreen's same pattern).456            onBack == null -> "peach"457            entry == null -> "entry"458            domain.isNotEmpty() -> domain459            else -> entry.title460        },461        leading = {462            if (onBack != null) {463                Icon(464                    Icons.AutoMirrored.Rounded.ArrowBack,465                    contentDescription = "back",466                    tint = peachColors.T1,467                    modifier = Modifier468                        .size(40.dp)469                        .clip(CircleShape)470                        .clickable(onClick = onBack)471                        .padding(PeachSpacing.sm),472                )473            } else {474                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))475            }476            // The favicon chip used to sit here, but it's the one loud,477            // colorful element in an otherwise quiet/muted bar (matching the478            // browser's persistent header, which carries no per-entry color479            // at all) — and it's redundant with the entry's own hero section480            // just below, which already shows its identity clearly.481        },482        actions = {483            // The chrome actions render on EVERY entry, not just the deck root.484            // They used to be gated on `onBack == null`, so an entry opened from485            // the vault list showed a bare back-arrow-and-title bar while the486            // deck root showed the full set — the same surface with two487            // different bars, which reads as the toolbar breaking mid-swipe.488            // The browser's header is persistent; this now is too. Order and489            // family mirror it: Notes, Settings, New entry, Delete, Lock, all490            // Outlined at one optical size.491            ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)492            ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)493            ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)494            if (entry != null) {495                ChromeAction(496                    Icons.Outlined.Delete,497                    "delete",498                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,499                    onClick = onDelete,500                )501            }502            ChromeAction(Icons.Outlined.Lock, "Lock vault", onClick = onLock)503        },504    )505}506507/** One shape for every top-bar action so the cluster reads as a set. */508@Composable509private fun ChromeAction(510    icon: androidx.compose.ui.graphics.vector.ImageVector,511    label: String,512    tint: Color = peachColors.T1,513    onClick: () -> Unit,514) {515    Icon(516        icon,517        contentDescription = label,518        tint = tint,519        modifier = Modifier520            .size(38.dp)521            .clip(CircleShape)522            .clickable(onClick = onClick)523            .padding(9.dp),524    )525}526527@Composable528private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {529    val accent = LocalPeachAccent.current530    var editing by remember(title) { mutableStateOf(false) }531    var draft by remember(title) { mutableStateOf(title) }532    if (!editing) {533        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {534            CharEnterText(535                text = title,536                style = PeachType.entryNameHero.copy(color = peachColors.T0),537            )538        }539        return540    }541    Column(Modifier.fillMaxWidth()) {542        BasicTextField(543            value = draft,544            onValueChange = { draft = it },545            singleLine = true,546            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),547            cursorBrush = SolidColor(accent),548            modifier = Modifier.fillMaxWidth().drawBehind {549                drawLine(550                    color = accent,551                    start = Offset(0f, size.height),552                    end = Offset(size.width, size.height),553                    strokeWidth = 1.dp.toPx(),554                )555            },556        )557        Spacer(Modifier.height(8.dp))558        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {559            Text(560                "✓ save", color = accent, style = PeachType.labelMono,561                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {562                    onSave(draft)563                    editing = false564                }.padding(vertical = 4.dp),565            )566            Text(567                "✗", color = peachColors.T2, style = PeachType.labelMono,568                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {569                    draft = title570                    editing = false571                }.padding(horizontal = 6.dp, vertical = 4.dp),572            )573        }574    }575}576577@Composable578private fun HomographWarning() {579    Row(580        Modifier.fillMaxWidth()581            .padding(top = 8.dp)582            .clip(RoundedCornerShape(8.dp))583            .background(peachColors.BreachBg)584            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))585            .padding(horizontal = 12.dp, vertical = 9.dp),586        verticalAlignment = Alignment.CenterVertically,587    ) {588        Text("!", color = peachColors.Breach, style = PeachType.labelMono)589        Spacer(Modifier.width(10.dp))590        Text(591            "This URL contains non-ASCII or punycode characters.",592            color = peachColors.Breach,593            style = PeachType.labelMono,594            modifier = Modifier.weight(1f),595        )596    }597}598599@Composable600private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {601    val passkey = entry.passkey ?: return602    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {603        Column(Modifier.weight(1f)) {604            Text(605                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,606                color = peachColors.T0,607                style = PeachType.body,608            )609            Text(610                listOfNotNull(611                    passkey.rpId,612                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,613                ).joinToString(" · "),614                color = peachColors.T2,615                style = PeachType.labelMono,616            )617            Text(618                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",619                color = peachColors.T2,620                style = PeachType.labelMono,621                modifier = Modifier.padding(top = 3.dp),622            )623        }624    }625}626627@Composable628private fun CanaryDetailSection(629    entryId: String,630    modifiedAt: Long,631    canary: com.peachpasswords.vault.Canary?,632    isPro: Boolean,633    message: String?,634    testing: Boolean,635    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,636    onTest: (webhookUrl: String) -> Unit,637) {638    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }639    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }640    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }641    val gated = !isPro && canary == null642    FieldShell("canary alert") {643        Column(Modifier.weight(1f)) {644            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {645                Text(646                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",647                    color = peachColors.T0,648                    style = PeachType.body,649                    modifier = Modifier.weight(1f),650                )651                if (gated) {652                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)653                }654                Text(655                    if (enabled) "on" else "off",656                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,657                    style = PeachType.labelMono,658                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {659                        if (gated) {660                            onSave(true, webhookUrl, label)661                        } else {662                            val next = !enabled663                            enabled = next664                            if (!next) onSave(false, webhookUrl, label)665                        }666                    }.padding(horizontal = 6.dp, vertical = 4.dp),667                )668            }669            Text(670                "Fires a silent webhook when this entry's password or username is copied.",671                color = peachColors.T2,672                style = PeachType.labelMono,673                modifier = Modifier.padding(top = 3.dp),674            )675            if (enabled) {676                Spacer(Modifier.height(8.dp))677                PeachField(678                    label = "webhook url",679                    value = webhookUrl,680                    onChange = { webhookUrl = it },681                )682                Text(683                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",684                    color = peachColors.T2,685                    style = PeachType.labelMono,686                )687                PeachField(688                    label = "label",689                    value = label,690                    onChange = { label = it },691                    mono = false,692                )693                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {694                    Text(695                        "save",696                        color = LocalPeachAccent.current,697                        style = PeachType.labelMono,698                        modifier = Modifier.clip(RoundedCornerShape(6.dp))699                            .clickable { onSave(true, webhookUrl, label) }700                            .padding(vertical = 4.dp),701                    )702                    Text(703                        if (testing) "sending..." else "test ping",704                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,705                        style = PeachType.labelMono,706                        modifier = Modifier.clip(RoundedCornerShape(6.dp))707                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }708                            .padding(vertical = 4.dp),709                    )710                }711            }712            message?.let {713                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))714            }715            if (canary?.lastPinged != null || canary?.pingCount != null) {716                Text(717                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),718                    color = peachColors.T2,719                    style = PeachType.labelMono,720                    modifier = Modifier.padding(top = 3.dp),721                )722            }723        }724    }725}726727@Composable728private fun EntryDetailSearchStrip(729    currentIndex: Int,730    totalEntries: Int,731    onOpenList: () -> Unit,732    onPrevious: () -> Unit,733    onNext: () -> Unit,734    modifier: Modifier = Modifier,735) {736    val accent = LocalPeachAccent.current737    val hasEntries = totalEntries > 0 && currentIndex >= 0738    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"739740    Column(741        modifier742            .fillMaxWidth()743    ) {744        // Gentle fade: scrolling content dissolves into the background745        // instead of hitting a hard opaque edge.746        Box(747            Modifier.fillMaxWidth().height(40.dp)748                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),749        )750        Column(751            Modifier.fillMaxWidth()752                .background(peachColors.Bg)753                .navigationBarsPadding(),754        ) {755            Row(756                Modifier757                    .fillMaxWidth()758                    .padding(horizontal = 16.dp, vertical = 12.dp)759                    .height(56.dp)760                    .clip(RoundedCornerShape(16.dp))761                    .background(peachColors.S1)762                    .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))763                    .padding(horizontal = 12.dp),764                verticalAlignment = Alignment.CenterVertically,765            ) {766                Row(767                    Modifier768                        .weight(1f)769                        .fillMaxHeight()770                        .clickable(onClick = onOpenList)771                        .pointerInput(onOpenList) {772                            var opened = false773                            detectVerticalDragGestures(774                                onDragEnd = { opened = false },775                                onDragCancel = { opened = false },776                            ) { _, dragAmount ->777                                if (!opened && dragAmount < -4f) {778                                    opened = true779                                    onOpenList()780                                }781                            }782                        },783                    verticalAlignment = Alignment.CenterVertically,784                ) {785                    Icon(786                        Icons.Rounded.Search,787                        contentDescription = null,788                        tint = accent,789                        modifier = Modifier.size(14.dp),790                    )791                    Spacer(Modifier.width(8.dp))792                    Text(793                        "search vault...",794                        color = peachColors.T2,795                        style = PeachType.searchInput,796                        modifier = Modifier.weight(1f),797                    )798                    Text(799                        counter,800                        color = peachColors.T2,801                        style = PeachType.labelMono,802                    )803                }804                Spacer(Modifier.width(8.dp))805                Icon(806                    Icons.AutoMirrored.Rounded.ArrowBack,807                    contentDescription = "Previous entry",808                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,809                    modifier = Modifier810                        .size(36.dp)811                        .clip(RoundedCornerShape(8.dp))812                        .clickable(enabled = hasEntries, onClick = onPrevious)813                        .padding(8.dp),814                )815                Spacer(Modifier.width(4.dp))816                Icon(817                    Icons.AutoMirrored.Rounded.ArrowForward,818                    contentDescription = "Next entry",819                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,820                    modifier = Modifier821                        .size(36.dp)822                        .clip(RoundedCornerShape(8.dp))823                        .clickable(enabled = hasEntries, onClick = onNext)824                        .padding(8.dp),825                )826            }827        }828    }829}830831/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */832@Composable833internal fun FieldShell(834    label: String,835    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,836) {837    val hairline = peachColors.T3838    Column(839        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {840            drawRect(841                color = hairline,842                topLeft = Offset(0f, size.height - 1.dp.toPx()),843                size = Size(size.width, 1.dp.toPx()),844            )845        },846    ) {847        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))848        Spacer(Modifier.height(6.dp))849        Row(verticalAlignment = Alignment.CenterVertically, content = content)850    }851}852853/**854 * Text for the ghost PeachScript watermark behind the entry name: the full title,855 * falling back to "P" for a blank title so the hero never renders empty.856 */857internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }858859internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =860    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)861862internal fun shortCredential(credentialId: String): String {863    if (credentialId.length <= 12) return credentialId864    return credentialId.take(6) + "..." + credentialId.takeLast(4)865}866867internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {868    val count = pingCount ?: 0869    val countLabel = "$count total alert" + if (count == 1) "" else "s"870    if (lastPinged == null) return countLabel871    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)872        .format(java.util.Date(lastPinged))873    return "Last triggered: $date · $countLabel"874}875876private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {877    val raw = url878    if (!raw.isNullOrBlank()) {879        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')880        if (cleaned.isNotEmpty()) return cleaned881    }882    return ""883}