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    PeachTopBar(453        title = when {454            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"455            // Deck root (no back target) mirrors the browser's persistent456            // header, which always shows the "peach" wordmark — never the457            // current entry's identity — leaving PeachMark in `leading` below458            // to complete the wordmark pairing (see LockScreen's same pattern).459            onBack == null -> "peach"460            entry == null -> "entry"461            domain.isNotEmpty() -> domain462            else -> entry.title463        },464        leading = {465            if (onBack != null) {466                Icon(467                    Icons.AutoMirrored.Rounded.ArrowBack,468                    contentDescription = "back",469                    tint = peachColors.T1,470                    modifier = Modifier471                        .size(40.dp)472                        .clip(CircleShape)473                        .clickable(onClick = onBack)474                        .padding(PeachSpacing.sm),475                )476            } else {477                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))478            }479            // The favicon chip used to sit here, but it's the one loud,480            // colorful element in an otherwise quiet/muted bar (matching the481            // browser's persistent header, which carries no per-entry color482            // at all) — and it's redundant with the entry's own hero section483            // just below, which already shows its identity clearly.484        },485        actions = {486            // The chrome actions render on EVERY entry, not just the deck root.487            // They used to be gated on `onBack == null`, so an entry opened from488            // the vault list showed a bare back-arrow-and-title bar while the489            // deck root showed the full set — the same surface with two490            // different bars, which reads as the toolbar breaking mid-swipe.491            // The browser's header is persistent; this now is too. Order and492            // family mirror it: Notes, Settings, New entry, Delete, Lock, all493            // Outlined at one optical size.494            ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)495            ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)496            ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)497            if (entry != null) {498                ChromeAction(499                    Icons.Outlined.Delete,500                    "delete",501                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,502                    onClick = onDelete,503                )504            }505            ChromeAction(Icons.Outlined.Lock, "Lock vault", onClick = onLock)506        },507    )508}509510/** One shape for every top-bar action so the cluster reads as a set. */511@Composable512private fun ChromeAction(513    icon: androidx.compose.ui.graphics.vector.ImageVector,514    label: String,515    tint: Color = peachColors.T1,516    onClick: () -> Unit,517) {518    Icon(519        icon,520        contentDescription = label,521        tint = tint,522        modifier = Modifier523            .size(38.dp)524            .clip(CircleShape)525            .clickable(onClick = onClick)526            .padding(9.dp),527    )528}529530@Composable531private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {532    val accent = LocalPeachAccent.current533    var editing by remember(title) { mutableStateOf(false) }534    var draft by remember(title) { mutableStateOf(title) }535    if (!editing) {536        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {537            CharEnterText(538                text = title,539                style = PeachType.entryNameHero.copy(color = peachColors.T0),540            )541        }542        return543    }544    Column(Modifier.fillMaxWidth()) {545        BasicTextField(546            value = draft,547            onValueChange = { draft = it },548            singleLine = true,549            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),550            cursorBrush = SolidColor(accent),551            modifier = Modifier.fillMaxWidth().drawBehind {552                drawLine(553                    color = accent,554                    start = Offset(0f, size.height),555                    end = Offset(size.width, size.height),556                    strokeWidth = 1.dp.toPx(),557                )558            },559        )560        Spacer(Modifier.height(8.dp))561        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {562            Text(563                "✓ save", color = accent, style = PeachType.labelMono,564                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {565                    onSave(draft)566                    editing = false567                }.padding(vertical = 4.dp),568            )569            Text(570                "✗", color = peachColors.T2, style = PeachType.labelMono,571                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {572                    draft = title573                    editing = false574                }.padding(horizontal = 6.dp, vertical = 4.dp),575            )576        }577    }578}579580@Composable581private fun HomographWarning() {582    Row(583        Modifier.fillMaxWidth()584            .padding(top = 8.dp)585            .clip(RoundedCornerShape(8.dp))586            .background(peachColors.BreachBg)587            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))588            .padding(horizontal = 12.dp, vertical = 9.dp),589        verticalAlignment = Alignment.CenterVertically,590    ) {591        Text("!", color = peachColors.Breach, style = PeachType.labelMono)592        Spacer(Modifier.width(10.dp))593        Text(594            "This URL contains non-ASCII or punycode characters.",595            color = peachColors.Breach,596            style = PeachType.labelMono,597            modifier = Modifier.weight(1f),598        )599    }600}601602@Composable603private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {604    val passkey = entry.passkey ?: return605    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {606        Column(Modifier.weight(1f)) {607            Text(608                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,609                color = peachColors.T0,610                style = PeachType.body,611            )612            Text(613                listOfNotNull(614                    passkey.rpId,615                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,616                ).joinToString(" · "),617                color = peachColors.T2,618                style = PeachType.labelMono,619            )620            Text(621                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",622                color = peachColors.T2,623                style = PeachType.labelMono,624                modifier = Modifier.padding(top = 3.dp),625            )626        }627    }628}629630@Composable631private fun CanaryDetailSection(632    entryId: String,633    modifiedAt: Long,634    canary: com.peachpasswords.vault.Canary?,635    isPro: Boolean,636    message: String?,637    testing: Boolean,638    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,639    onTest: (webhookUrl: String) -> Unit,640) {641    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }642    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }643    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }644    val gated = !isPro && canary == null645    FieldShell("canary alert") {646        Column(Modifier.weight(1f)) {647            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {648                Text(649                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",650                    color = peachColors.T0,651                    style = PeachType.body,652                    modifier = Modifier.weight(1f),653                )654                if (gated) {655                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)656                }657                Text(658                    if (enabled) "on" else "off",659                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,660                    style = PeachType.labelMono,661                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {662                        if (gated) {663                            onSave(true, webhookUrl, label)664                        } else {665                            val next = !enabled666                            enabled = next667                            if (!next) onSave(false, webhookUrl, label)668                        }669                    }.padding(horizontal = 6.dp, vertical = 4.dp),670                )671            }672            Text(673                "Fires a silent webhook when this entry's password or username is copied.",674                color = peachColors.T2,675                style = PeachType.labelMono,676                modifier = Modifier.padding(top = 3.dp),677            )678            if (enabled) {679                Spacer(Modifier.height(8.dp))680                PeachField(681                    label = "webhook url",682                    value = webhookUrl,683                    onChange = { webhookUrl = it },684                )685                Text(686                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",687                    color = peachColors.T2,688                    style = PeachType.labelMono,689                )690                PeachField(691                    label = "label",692                    value = label,693                    onChange = { label = it },694                    mono = false,695                )696                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {697                    Text(698                        "save",699                        color = LocalPeachAccent.current,700                        style = PeachType.labelMono,701                        modifier = Modifier.clip(RoundedCornerShape(6.dp))702                            .clickable { onSave(true, webhookUrl, label) }703                            .padding(vertical = 4.dp),704                    )705                    Text(706                        if (testing) "sending..." else "test ping",707                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,708                        style = PeachType.labelMono,709                        modifier = Modifier.clip(RoundedCornerShape(6.dp))710                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }711                            .padding(vertical = 4.dp),712                    )713                }714            }715            message?.let {716                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))717            }718            if (canary?.lastPinged != null || canary?.pingCount != null) {719                Text(720                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),721                    color = peachColors.T2,722                    style = PeachType.labelMono,723                    modifier = Modifier.padding(top = 3.dp),724                )725            }726        }727    }728}729730@Composable731private fun EntryDetailSearchStrip(732    currentIndex: Int,733    totalEntries: Int,734    onOpenList: () -> Unit,735    onPrevious: () -> Unit,736    onNext: () -> Unit,737    modifier: Modifier = Modifier,738) {739    val accent = LocalPeachAccent.current740    val hasEntries = totalEntries > 0 && currentIndex >= 0741    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"742743    Column(744        modifier745            .fillMaxWidth()746    ) {747        // Gentle fade: scrolling content dissolves into the background748        // instead of hitting a hard opaque edge.749        Box(750            Modifier.fillMaxWidth().height(40.dp)751                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),752        )753        Column(754            Modifier.fillMaxWidth()755                .background(peachColors.Bg)756                .navigationBarsPadding(),757        ) {758            Row(759                Modifier760                    .fillMaxWidth()761                    .padding(horizontal = 16.dp, vertical = 12.dp)762                    .height(56.dp)763                    .clip(RoundedCornerShape(16.dp))764                    .background(peachColors.S1)765                    .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))766                    .padding(horizontal = 12.dp),767                verticalAlignment = Alignment.CenterVertically,768            ) {769                Row(770                    Modifier771                        .weight(1f)772                        .fillMaxHeight()773                        .clickable(onClick = onOpenList)774                        .pointerInput(onOpenList) {775                            var opened = false776                            detectVerticalDragGestures(777                                onDragEnd = { opened = false },778                                onDragCancel = { opened = false },779                            ) { _, dragAmount ->780                                if (!opened && dragAmount < -4f) {781                                    opened = true782                                    onOpenList()783                                }784                            }785                        },786                    verticalAlignment = Alignment.CenterVertically,787                ) {788                    Icon(789                        Icons.Rounded.Search,790                        contentDescription = null,791                        tint = accent,792                        modifier = Modifier.size(14.dp),793                    )794                    Spacer(Modifier.width(8.dp))795                    Text(796                        "search vault...",797                        color = peachColors.T2,798                        style = PeachType.searchInput,799                        modifier = Modifier.weight(1f),800                    )801                    Text(802                        counter,803                        color = peachColors.T2,804                        style = PeachType.labelMono,805                    )806                }807                Spacer(Modifier.width(8.dp))808                Icon(809                    Icons.AutoMirrored.Rounded.ArrowBack,810                    contentDescription = "Previous entry",811                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,812                    modifier = Modifier813                        .size(36.dp)814                        .clip(RoundedCornerShape(8.dp))815                        .clickable(enabled = hasEntries, onClick = onPrevious)816                        .padding(8.dp),817                )818                Spacer(Modifier.width(4.dp))819                Icon(820                    Icons.AutoMirrored.Rounded.ArrowForward,821                    contentDescription = "Next entry",822                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,823                    modifier = Modifier824                        .size(36.dp)825                        .clip(RoundedCornerShape(8.dp))826                        .clickable(enabled = hasEntries, onClick = onNext)827                        .padding(8.dp),828                )829            }830        }831    }832}833834/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */835@Composable836internal fun FieldShell(837    label: String,838    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,839) {840    val hairline = peachColors.T3841    Column(842        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {843            drawRect(844                color = hairline,845                topLeft = Offset(0f, size.height - 1.dp.toPx()),846                size = Size(size.width, 1.dp.toPx()),847            )848        },849    ) {850        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))851        Spacer(Modifier.height(6.dp))852        Row(verticalAlignment = Alignment.CenterVertically, content = content)853    }854}855856/**857 * Text for the ghost PeachScript watermark behind the entry name: the full title,858 * falling back to "P" for a blank title so the hero never renders empty.859 */860internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }861862internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =863    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)864865internal fun shortCredential(credentialId: String): String {866    if (credentialId.length <= 12) return credentialId867    return credentialId.take(6) + "..." + credentialId.takeLast(4)868}869870internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {871    val count = pingCount ?: 0872    val countLabel = "$count total alert" + if (count == 1) "" else "s"873    if (lastPinged == null) return countLabel874    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)875        .format(java.util.Date(lastPinged))876    return "Last triggered: $date · $countLabel"877}878879private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {880    val raw = url881    if (!raw.isNullOrBlank()) {882        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')883        if (cleaned.isNotEmpty()) return cleaned884    }885    return ""886}