EntryDetailScreen.kt

39.2 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    SecureFlagEffect()115116    val entry by vm.entry.collectAsState()117    val activeEntries by vm.activeEntries.collectAsState()118    val identitySuggestions by vm.identitySuggestions.collectAsState()119    val allTags by vm.allTags.collectAsState()120    val isPro by vm.isPro.collectAsState()121    val cvcRevealed by vm.cvcRevealed.collectAsState()122    var cardRevealed by remember { mutableStateOf(false) }123    val copiedField by vm.copiedField.collectAsState()124    val canaryMessage by vm.canaryMessage.collectAsState()125    val canaryTesting by vm.canaryTesting.collectAsState()126    val context = LocalContext.current127    val accent = LocalPeachAccent.current128    var confirmingDelete by remember { mutableStateOf(false) }129    var generatorOpen by remember { mutableStateOf(false) }130    var historyExpanded by remember { mutableStateOf(false) }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                    // Always show the history affordance (browser parity) — visible336                    // even before any password change, expanding to an empty-state337                    // explainer so the feature is discoverable rather than hidden338                    // until history already exists.339                    val history = e.passwordHistory.orEmpty()340                    PasswordHistoryToggle(341                        count = history.size,342                        expanded = historyExpanded,343                        onToggle = { historyExpanded = !historyExpanded },344                    )345                    AnimatedVisibility(visible = historyExpanded) {346                        Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {347                            PasswordHistoryList(348                                history = history,349                                onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },350                                onCopy = { oldPw -> vm.copy("password", oldPw) },351                            )352                        }353                    }354                    if (e.hasTOTP()) {355                        Spacer(Modifier.height(4.dp))356                        TotpBandEditable(357                            totpField = e.totp!!,358                            copied = copiedField == "totp",359                            onCopy = vm::copyTotp,360                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },361                        )362                    }363                }364                e.notes?.takeIf { it.isNotBlank() }?.let { v ->365                    // A note authored in the browser is HTML: shown rendered,366                    // edited as markup, saved back as the same tags.367                    val notesAreHtml = isNoteHtml(v)368                    EditableFieldRow(369                        label = "notes", field = "notes", value = v,370                        copied = copiedField == "notes",371                        onCopy = { vm.copy("notes", notePlainText(v)) },372                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },373                        mono = false, singleLine = false,374                        displayText = noteRendered(v),375                        draftText = noteEditorText(v),376                    )377                }378                DetailTagRow(379                    tags = e.tags.orEmpty(),380                    allTags = allTags,381                    onAddTag = vm::addTag,382                    onRemoveTag = vm::removeTag,383                )384                if (!e.isNote() && !e.isCard()) {385                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),386                    // unless a canary is already configured for this entry — every387                    // entry showing an "Off" canary block permanently was clutter388                    // most people never touch.389                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }390                    if (advancedExpanded) {391                        CanaryDetailSection(392                            entryId = e.id,393                            modifiedAt = e.modifiedAt,394                            canary = e.canary,395                            isPro = isPro,396                            message = canaryMessage,397                            testing = canaryTesting,398                            onSave = vm::saveCanary,399                            onTest = vm::testCanary,400                        )401                    } else {402                        Text(403                            "+ advanced",404                            color = LocalPeachAccent.current,405                            style = PeachType.labelMono,406                            modifier = Modifier407                                .clip(RoundedCornerShape(6.dp))408                                .clickable { advancedExpanded = true }409                                .padding(vertical = 6.dp),410                        )411                    }412                }413414                Spacer(Modifier.height(PeachSpacing.xxl))415            }416417            if (e != null) {418                EntryDetailSearchStrip(419                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },420                    totalEntries = activeEntries.size,421                    onOpenList = { onNavigate(PeachNav.VaultList) },422                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },423                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },424                    modifier = Modifier.align(Alignment.BottomCenter),425                )426            }427428            if (generatorOpen) {429                GeneratorSheet(430                    copied = copiedField == "genDraft",431                    onCopy = { draft -> vm.copy("genDraft", draft) },432                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },433                    onDismiss = { generatorOpen = false },434                )435            }436        }437    }438}439440@Composable441private fun EntryDetailTopBar(442    entry: com.peachpasswords.vault.VaultEntry?,443    confirmingDelete: Boolean,444    onBack: (() -> Unit)?,445    onLock: () -> Unit,446    onDelete: () -> Unit,447    onAddEntry: () -> Unit,448    onSettings: () -> Unit,449    onOpenNotes: () -> Unit,450) {451    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }452    val accent = LocalPeachAccent.current453    PeachTopBar(454        title = when {455            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"456            // Deck root (no back target) mirrors the browser's persistent457            // header, which always shows the "peach" wordmark — never the458            // current entry's identity — leaving PeachMark in `leading` below459            // to complete the wordmark pairing (see LockScreen's same pattern).460            onBack == null -> "peach"461            entry == null -> "entry"462            domain.isNotEmpty() -> domain463            else -> entry.title464        },465        leading = {466            if (onBack != null) {467                Icon(468                    Icons.AutoMirrored.Rounded.ArrowBack,469                    contentDescription = "back",470                    tint = peachColors.T1,471                    modifier = Modifier472                        .size(40.dp)473                        .clip(CircleShape)474                        .clickable(onClick = onBack)475                        .padding(PeachSpacing.sm),476                )477            } else {478                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))479            }480            // The favicon chip used to sit here, but it's the one loud,481            // colorful element in an otherwise quiet/muted bar (matching the482            // browser's persistent header, which carries no per-entry color483            // at all) — and it's redundant with the entry's own hero section484            // just below, which already shows its identity clearly.485        },486        actions = {487            // The chrome actions render on EVERY entry, not just the deck root.488            // They used to be gated on `onBack == null`, so an entry opened from489            // the vault list showed a bare back-arrow-and-title bar while the490            // deck root showed the full set — the same surface with two491            // different bars, which reads as the toolbar breaking mid-swipe.492            // The browser's header is persistent; this now is too. Order and493            // family mirror it: Notes, Settings, New entry, Delete, Lock, all494            // Outlined at one optical size.495            ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)496            ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)497            ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)498            if (entry != null) {499                ChromeAction(500                    Icons.Outlined.Delete,501                    "delete",502                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,503                    onClick = onDelete,504                )505            }506            ChromeAction(Icons.Outlined.Lock, "Lock vault", tint = accent, onClick = onLock)507        },508    )509}510511/** One shape for every top-bar action so the cluster reads as a set. */512@Composable513private fun ChromeAction(514    icon: androidx.compose.ui.graphics.vector.ImageVector,515    label: String,516    tint: Color = peachColors.T1,517    onClick: () -> Unit,518) {519    Icon(520        icon,521        contentDescription = label,522        tint = tint,523        modifier = Modifier524            .size(38.dp)525            .clip(CircleShape)526            .clickable(onClick = onClick)527            .padding(9.dp),528    )529}530531@Composable532private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {533    val accent = LocalPeachAccent.current534    var editing by remember(title) { mutableStateOf(false) }535    var draft by remember(title) { mutableStateOf(title) }536    if (!editing) {537        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {538            CharEnterText(539                text = title,540                style = PeachType.entryNameHero.copy(color = peachColors.T0),541            )542        }543        return544    }545    Column(Modifier.fillMaxWidth()) {546        BasicTextField(547            value = draft,548            onValueChange = { draft = it },549            singleLine = true,550            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),551            cursorBrush = SolidColor(accent),552            modifier = Modifier.fillMaxWidth().drawBehind {553                drawLine(554                    color = accent,555                    start = Offset(0f, size.height),556                    end = Offset(size.width, size.height),557                    strokeWidth = 1.dp.toPx(),558                )559            },560        )561        Spacer(Modifier.height(8.dp))562        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {563            Text(564                "✓ save", color = accent, style = PeachType.labelMono,565                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {566                    onSave(draft)567                    editing = false568                }.padding(vertical = 4.dp),569            )570            Text(571                "✗", color = peachColors.T2, style = PeachType.labelMono,572                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {573                    draft = title574                    editing = false575                }.padding(horizontal = 6.dp, vertical = 4.dp),576            )577        }578    }579}580581@Composable582private fun HomographWarning() {583    Row(584        Modifier.fillMaxWidth()585            .padding(top = 8.dp)586            .clip(RoundedCornerShape(8.dp))587            .background(peachColors.BreachBg)588            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))589            .padding(horizontal = 12.dp, vertical = 9.dp),590        verticalAlignment = Alignment.CenterVertically,591    ) {592        Text("!", color = peachColors.Breach, style = PeachType.labelMono)593        Spacer(Modifier.width(10.dp))594        Text(595            "This URL contains non-ASCII or punycode characters.",596            color = peachColors.Breach,597            style = PeachType.labelMono,598            modifier = Modifier.weight(1f),599        )600    }601}602603@Composable604private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {605    val passkey = entry.passkey ?: return606    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {607        Column(Modifier.weight(1f)) {608            Text(609                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,610                color = peachColors.T0,611                style = PeachType.body,612            )613            Text(614                listOfNotNull(615                    passkey.rpId,616                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,617                ).joinToString(" · "),618                color = peachColors.T2,619                style = PeachType.labelMono,620            )621            Text(622                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",623                color = peachColors.T2,624                style = PeachType.labelMono,625                modifier = Modifier.padding(top = 3.dp),626            )627        }628    }629}630631@Composable632private fun CanaryDetailSection(633    entryId: String,634    modifiedAt: Long,635    canary: com.peachpasswords.vault.Canary?,636    isPro: Boolean,637    message: String?,638    testing: Boolean,639    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,640    onTest: (webhookUrl: String) -> Unit,641) {642    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }643    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }644    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }645    val gated = !isPro && canary == null646    FieldShell("canary alert") {647        Column(Modifier.weight(1f)) {648            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {649                Text(650                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",651                    color = peachColors.T0,652                    style = PeachType.body,653                    modifier = Modifier.weight(1f),654                )655                if (gated) {656                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)657                }658                Text(659                    if (enabled) "on" else "off",660                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,661                    style = PeachType.labelMono,662                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {663                        if (gated) {664                            onSave(true, webhookUrl, label)665                        } else {666                            val next = !enabled667                            enabled = next668                            if (!next) onSave(false, webhookUrl, label)669                        }670                    }.padding(horizontal = 6.dp, vertical = 4.dp),671                )672            }673            Text(674                "Fires a silent webhook when this entry's password or username is copied.",675                color = peachColors.T2,676                style = PeachType.labelMono,677                modifier = Modifier.padding(top = 3.dp),678            )679            if (enabled) {680                Spacer(Modifier.height(8.dp))681                PeachField(682                    label = "webhook url",683                    value = webhookUrl,684                    onChange = { webhookUrl = it },685                )686                Text(687                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",688                    color = peachColors.T2,689                    style = PeachType.labelMono,690                )691                PeachField(692                    label = "label",693                    value = label,694                    onChange = { label = it },695                    mono = false,696                )697                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {698                    Text(699                        "save",700                        color = LocalPeachAccent.current,701                        style = PeachType.labelMono,702                        modifier = Modifier.clip(RoundedCornerShape(6.dp))703                            .clickable { onSave(true, webhookUrl, label) }704                            .padding(vertical = 4.dp),705                    )706                    Text(707                        if (testing) "sending..." else "test ping",708                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,709                        style = PeachType.labelMono,710                        modifier = Modifier.clip(RoundedCornerShape(6.dp))711                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }712                            .padding(vertical = 4.dp),713                    )714                }715            }716            message?.let {717                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))718            }719            if (canary?.lastPinged != null || canary?.pingCount != null) {720                Text(721                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),722                    color = peachColors.T2,723                    style = PeachType.labelMono,724                    modifier = Modifier.padding(top = 3.dp),725                )726            }727        }728    }729}730731@Composable732private fun EntryDetailSearchStrip(733    currentIndex: Int,734    totalEntries: Int,735    onOpenList: () -> Unit,736    onPrevious: () -> Unit,737    onNext: () -> Unit,738    modifier: Modifier = Modifier,739) {740    val accent = LocalPeachAccent.current741    val hasEntries = totalEntries > 0 && currentIndex >= 0742    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"743744    Column(745        modifier746            .fillMaxWidth()747    ) {748        // Gentle fade: scrolling content dissolves into the background749        // instead of hitting a hard opaque edge.750        Box(751            Modifier.fillMaxWidth().height(40.dp)752                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),753        )754        Column(755            Modifier.fillMaxWidth()756                .background(peachColors.Bg)757                .navigationBarsPadding(),758        ) {759            Row(760                Modifier761                    .fillMaxWidth()762                    .padding(horizontal = 16.dp, vertical = 12.dp)763                    .height(56.dp)764                    .clip(RoundedCornerShape(16.dp))765                    .background(peachColors.S1)766                    .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))767                    .padding(horizontal = 12.dp),768                verticalAlignment = Alignment.CenterVertically,769            ) {770                Row(771                    Modifier772                        .weight(1f)773                        .fillMaxHeight()774                        .clickable(onClick = onOpenList)775                        .pointerInput(onOpenList) {776                            var opened = false777                            detectVerticalDragGestures(778                                onDragEnd = { opened = false },779                                onDragCancel = { opened = false },780                            ) { _, dragAmount ->781                                if (!opened && dragAmount < -4f) {782                                    opened = true783                                    onOpenList()784                                }785                            }786                        },787                    verticalAlignment = Alignment.CenterVertically,788                ) {789                    Icon(790                        Icons.Rounded.Search,791                        contentDescription = null,792                        tint = accent,793                        modifier = Modifier.size(14.dp),794                    )795                    Spacer(Modifier.width(8.dp))796                    Text(797                        "search vault...",798                        color = peachColors.T2,799                        style = PeachType.searchInput,800                        modifier = Modifier.weight(1f),801                    )802                    Text(803                        counter,804                        color = peachColors.T2,805                        style = PeachType.labelMono,806                    )807                }808                Spacer(Modifier.width(8.dp))809                Icon(810                    Icons.AutoMirrored.Rounded.ArrowBack,811                    contentDescription = "Previous entry",812                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,813                    modifier = Modifier814                        .size(36.dp)815                        .clip(RoundedCornerShape(8.dp))816                        .clickable(enabled = hasEntries, onClick = onPrevious)817                        .padding(8.dp),818                )819                Spacer(Modifier.width(4.dp))820                Icon(821                    Icons.AutoMirrored.Rounded.ArrowForward,822                    contentDescription = "Next entry",823                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,824                    modifier = Modifier825                        .size(36.dp)826                        .clip(RoundedCornerShape(8.dp))827                        .clickable(enabled = hasEntries, onClick = onNext)828                        .padding(8.dp),829                )830            }831        }832    }833}834835/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */836@Composable837internal fun FieldShell(838    label: String,839    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,840) {841    val hairline = peachColors.T3842    Column(843        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {844            drawRect(845                color = hairline,846                topLeft = Offset(0f, size.height - 1.dp.toPx()),847                size = Size(size.width, 1.dp.toPx()),848            )849        },850    ) {851        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))852        Spacer(Modifier.height(6.dp))853        Row(verticalAlignment = Alignment.CenterVertically, content = content)854    }855}856857/**858 * Text for the ghost PeachScript watermark behind the entry name: the full title,859 * falling back to "P" for a blank title so the hero never renders empty.860 */861internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }862863internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =864    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)865866internal fun shortCredential(credentialId: String): String {867    if (credentialId.length <= 12) return credentialId868    return credentialId.take(6) + "..." + credentialId.takeLast(4)869}870871internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {872    val count = pingCount ?: 0873    val countLabel = "$count total alert" + if (count == 1) "" else "s"874    if (lastPinged == null) return countLabel875    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)876        .format(java.util.Date(lastPinged))877    return "Last triggered: $date · $countLabel"878}879880private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {881    val raw = url882    if (!raw.isNullOrBlank()) {883        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')884        if (cleaned.isNotEmpty()) return cleaned885    }886    return ""887}