EntryDetailScreen.kt

38.8 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                    val history = e.passwordHistory.orEmpty()333                    if (history.isNotEmpty()) {334                        PasswordHistoryToggle(335                            count = history.size,336                            expanded = historyExpanded,337                            onToggle = { historyExpanded = !historyExpanded },338                        )339                        AnimatedVisibility(visible = historyExpanded) {340                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {341                                PasswordHistoryList(342                                    history = history,343                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },344                                    onCopy = { oldPw -> vm.copy("password", oldPw) },345                                )346                            }347                        }348                    }349                    if (e.hasTOTP()) {350                        Spacer(Modifier.height(4.dp))351                        TotpBandEditable(352                            totpField = e.totp!!,353                            copied = copiedField == "totp",354                            onCopy = vm::copyTotp,355                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },356                        )357                    }358                }359                e.notes?.takeIf { it.isNotBlank() }?.let { v ->360                    // A note authored in the browser is HTML: shown rendered,361                    // edited as markup, saved back as the same tags.362                    val notesAreHtml = isNoteHtml(v)363                    EditableFieldRow(364                        label = "notes", field = "notes", value = v,365                        copied = copiedField == "notes",366                        onCopy = { vm.copy("notes", notePlainText(v)) },367                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },368                        mono = false, singleLine = false,369                        displayText = noteRendered(v),370                        draftText = noteEditorText(v),371                    )372                }373                DetailTagRow(374                    tags = e.tags.orEmpty(),375                    allTags = allTags,376                    onAddTag = vm::addTag,377                    onRemoveTag = vm::removeTag,378                )379                if (!e.isNote() && !e.isCard()) {380                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),381                    // unless a canary is already configured for this entry — every382                    // entry showing an "Off" canary block permanently was clutter383                    // most people never touch.384                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }385                    if (advancedExpanded) {386                        CanaryDetailSection(387                            entryId = e.id,388                            modifiedAt = e.modifiedAt,389                            canary = e.canary,390                            isPro = isPro,391                            message = canaryMessage,392                            testing = canaryTesting,393                            onSave = vm::saveCanary,394                            onTest = vm::testCanary,395                        )396                    } else {397                        Text(398                            "+ advanced",399                            color = LocalPeachAccent.current,400                            style = PeachType.labelMono,401                            modifier = Modifier402                                .clip(RoundedCornerShape(6.dp))403                                .clickable { advancedExpanded = true }404                                .padding(vertical = 6.dp),405                        )406                    }407                }408409                Spacer(Modifier.height(PeachSpacing.xxl))410            }411412            if (e != null) {413                EntryDetailSearchStrip(414                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },415                    totalEntries = activeEntries.size,416                    onOpenList = { onNavigate(PeachNav.VaultList) },417                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },418                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },419                    modifier = Modifier.align(Alignment.BottomCenter),420                )421            }422423            if (generatorOpen) {424                GeneratorSheet(425                    copied = copiedField == "genDraft",426                    onCopy = { draft -> vm.copy("genDraft", draft) },427                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },428                    onDismiss = { generatorOpen = false },429                )430            }431        }432    }433}434435@Composable436private fun EntryDetailTopBar(437    entry: com.peachpasswords.vault.VaultEntry?,438    confirmingDelete: Boolean,439    onBack: (() -> Unit)?,440    onLock: () -> Unit,441    onDelete: () -> Unit,442    onAddEntry: () -> Unit,443    onSettings: () -> Unit,444    onOpenNotes: () -> Unit,445) {446    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }447    PeachTopBar(448        title = when {449            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"450            // Deck root (no back target) mirrors the browser's persistent451            // header, which always shows the "peach" wordmark — never the452            // current entry's identity — leaving PeachMark in `leading` below453            // to complete the wordmark pairing (see LockScreen's same pattern).454            onBack == null -> "peach"455            entry == null -> "entry"456            domain.isNotEmpty() -> domain457            else -> entry.title458        },459        leading = {460            if (onBack != null) {461                Icon(462                    Icons.AutoMirrored.Rounded.ArrowBack,463                    contentDescription = "back",464                    tint = peachColors.T1,465                    modifier = Modifier466                        .size(40.dp)467                        .clip(CircleShape)468                        .clickable(onClick = onBack)469                        .padding(PeachSpacing.sm),470                )471            } else {472                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))473            }474            // The favicon chip used to sit here, but it's the one loud,475            // colorful element in an otherwise quiet/muted bar (matching the476            // browser's persistent header, which carries no per-entry color477            // at all) — and it's redundant with the entry's own hero section478            // just below, which already shows its identity clearly.479        },480        actions = {481            // The chrome actions render on EVERY entry, not just the deck root.482            // They used to be gated on `onBack == null`, so an entry opened from483            // the vault list showed a bare back-arrow-and-title bar while the484            // deck root showed the full set — the same surface with two485            // different bars, which reads as the toolbar breaking mid-swipe.486            // The browser's header is persistent; this now is too. Order and487            // family mirror it: Notes, Settings, New entry, Delete, Lock, all488            // Outlined at one optical size.489            ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)490            ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)491            ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)492            if (entry != null) {493                ChromeAction(494                    Icons.Outlined.Delete,495                    "delete",496                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,497                    onClick = onDelete,498                )499            }500            ChromeAction(Icons.Outlined.Lock, "Lock vault", onClick = onLock)501        },502    )503}504505/** One shape for every top-bar action so the cluster reads as a set. */506@Composable507private fun ChromeAction(508    icon: androidx.compose.ui.graphics.vector.ImageVector,509    label: String,510    tint: Color = peachColors.T1,511    onClick: () -> Unit,512) {513    Icon(514        icon,515        contentDescription = label,516        tint = tint,517        modifier = Modifier518            .size(38.dp)519            .clip(CircleShape)520            .clickable(onClick = onClick)521            .padding(9.dp),522    )523}524525@Composable526private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {527    val accent = LocalPeachAccent.current528    var editing by remember(title) { mutableStateOf(false) }529    var draft by remember(title) { mutableStateOf(title) }530    if (!editing) {531        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {532            CharEnterText(533                text = title,534                style = PeachType.entryNameHero.copy(color = peachColors.T0),535            )536        }537        return538    }539    Column(Modifier.fillMaxWidth()) {540        BasicTextField(541            value = draft,542            onValueChange = { draft = it },543            singleLine = true,544            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),545            cursorBrush = SolidColor(accent),546            modifier = Modifier.fillMaxWidth().drawBehind {547                drawLine(548                    color = accent,549                    start = Offset(0f, size.height),550                    end = Offset(size.width, size.height),551                    strokeWidth = 1.dp.toPx(),552                )553            },554        )555        Spacer(Modifier.height(8.dp))556        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {557            Text(558                "✓ save", color = accent, style = PeachType.labelMono,559                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {560                    onSave(draft)561                    editing = false562                }.padding(vertical = 4.dp),563            )564            Text(565                "✗", color = peachColors.T2, style = PeachType.labelMono,566                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {567                    draft = title568                    editing = false569                }.padding(horizontal = 6.dp, vertical = 4.dp),570            )571        }572    }573}574575@Composable576private fun HomographWarning() {577    Row(578        Modifier.fillMaxWidth()579            .padding(top = 8.dp)580            .clip(RoundedCornerShape(8.dp))581            .background(peachColors.BreachBg)582            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))583            .padding(horizontal = 12.dp, vertical = 9.dp),584        verticalAlignment = Alignment.CenterVertically,585    ) {586        Text("!", color = peachColors.Breach, style = PeachType.labelMono)587        Spacer(Modifier.width(10.dp))588        Text(589            "This URL contains non-ASCII or punycode characters.",590            color = peachColors.Breach,591            style = PeachType.labelMono,592            modifier = Modifier.weight(1f),593        )594    }595}596597@Composable598private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {599    val passkey = entry.passkey ?: return600    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {601        Column(Modifier.weight(1f)) {602            Text(603                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,604                color = peachColors.T0,605                style = PeachType.body,606            )607            Text(608                listOfNotNull(609                    passkey.rpId,610                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,611                ).joinToString(" · "),612                color = peachColors.T2,613                style = PeachType.labelMono,614            )615            Text(616                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",617                color = peachColors.T2,618                style = PeachType.labelMono,619                modifier = Modifier.padding(top = 3.dp),620            )621        }622    }623}624625@Composable626private fun CanaryDetailSection(627    entryId: String,628    modifiedAt: Long,629    canary: com.peachpasswords.vault.Canary?,630    isPro: Boolean,631    message: String?,632    testing: Boolean,633    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,634    onTest: (webhookUrl: String) -> Unit,635) {636    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }637    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }638    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }639    val gated = !isPro && canary == null640    FieldShell("canary alert") {641        Column(Modifier.weight(1f)) {642            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {643                Text(644                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",645                    color = peachColors.T0,646                    style = PeachType.body,647                    modifier = Modifier.weight(1f),648                )649                if (gated) {650                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)651                }652                Text(653                    if (enabled) "on" else "off",654                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,655                    style = PeachType.labelMono,656                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {657                        if (gated) {658                            onSave(true, webhookUrl, label)659                        } else {660                            val next = !enabled661                            enabled = next662                            if (!next) onSave(false, webhookUrl, label)663                        }664                    }.padding(horizontal = 6.dp, vertical = 4.dp),665                )666            }667            Text(668                "Fires a silent webhook when this entry's password or username is copied.",669                color = peachColors.T2,670                style = PeachType.labelMono,671                modifier = Modifier.padding(top = 3.dp),672            )673            if (enabled) {674                Spacer(Modifier.height(8.dp))675                PeachField(676                    label = "webhook url",677                    value = webhookUrl,678                    onChange = { webhookUrl = it },679                )680                Text(681                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",682                    color = peachColors.T2,683                    style = PeachType.labelMono,684                )685                PeachField(686                    label = "label",687                    value = label,688                    onChange = { label = it },689                    mono = false,690                )691                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {692                    Text(693                        "save",694                        color = LocalPeachAccent.current,695                        style = PeachType.labelMono,696                        modifier = Modifier.clip(RoundedCornerShape(6.dp))697                            .clickable { onSave(true, webhookUrl, label) }698                            .padding(vertical = 4.dp),699                    )700                    Text(701                        if (testing) "sending..." else "test ping",702                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,703                        style = PeachType.labelMono,704                        modifier = Modifier.clip(RoundedCornerShape(6.dp))705                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }706                            .padding(vertical = 4.dp),707                    )708                }709            }710            message?.let {711                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))712            }713            if (canary?.lastPinged != null || canary?.pingCount != null) {714                Text(715                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),716                    color = peachColors.T2,717                    style = PeachType.labelMono,718                    modifier = Modifier.padding(top = 3.dp),719                )720            }721        }722    }723}724725@Composable726private fun EntryDetailSearchStrip(727    currentIndex: Int,728    totalEntries: Int,729    onOpenList: () -> Unit,730    onPrevious: () -> Unit,731    onNext: () -> Unit,732    modifier: Modifier = Modifier,733) {734    val accent = LocalPeachAccent.current735    val hasEntries = totalEntries > 0 && currentIndex >= 0736    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"737738    Column(739        modifier740            .fillMaxWidth()741    ) {742        // Gentle fade: scrolling content dissolves into the background743        // instead of hitting a hard opaque edge.744        Box(745            Modifier.fillMaxWidth().height(40.dp)746                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),747        )748        Column(749            Modifier.fillMaxWidth()750                .background(peachColors.Bg)751                .navigationBarsPadding(),752        ) {753            Row(754                Modifier755                    .fillMaxWidth()756                    .padding(horizontal = 16.dp, vertical = 12.dp)757                    .height(56.dp)758                    .clip(RoundedCornerShape(16.dp))759                    .background(peachColors.S1)760                    .padding(horizontal = 12.dp),761                verticalAlignment = Alignment.CenterVertically,762            ) {763                Row(764                    Modifier765                        .weight(1f)766                        .fillMaxHeight()767                        .clickable(onClick = onOpenList)768                        .pointerInput(onOpenList) {769                            var opened = false770                            detectVerticalDragGestures(771                                onDragEnd = { opened = false },772                                onDragCancel = { opened = false },773                            ) { _, dragAmount ->774                                if (!opened && dragAmount < -4f) {775                                    opened = true776                                    onOpenList()777                                }778                            }779                        },780                    verticalAlignment = Alignment.CenterVertically,781                ) {782                    Icon(783                        Icons.Rounded.Search,784                        contentDescription = null,785                        tint = accent,786                        modifier = Modifier.size(14.dp),787                    )788                    Spacer(Modifier.width(8.dp))789                    Text(790                        "search vault...",791                        color = peachColors.T2,792                        style = PeachType.searchInput,793                        modifier = Modifier.weight(1f),794                    )795                    Text(796                        counter,797                        color = peachColors.T2,798                        style = PeachType.labelMono,799                    )800                }801                Spacer(Modifier.width(8.dp))802                Icon(803                    Icons.AutoMirrored.Rounded.ArrowBack,804                    contentDescription = "Previous entry",805                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,806                    modifier = Modifier807                        .size(36.dp)808                        .clip(RoundedCornerShape(8.dp))809                        .clickable(enabled = hasEntries, onClick = onPrevious)810                        .padding(8.dp),811                )812                Spacer(Modifier.width(4.dp))813                Icon(814                    Icons.AutoMirrored.Rounded.ArrowForward,815                    contentDescription = "Next entry",816                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,817                    modifier = Modifier818                        .size(36.dp)819                        .clip(RoundedCornerShape(8.dp))820                        .clickable(enabled = hasEntries, onClick = onNext)821                        .padding(8.dp),822                )823            }824        }825    }826}827828/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */829@Composable830internal fun FieldShell(831    label: String,832    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,833) {834    val hairline = peachColors.T3835    Column(836        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {837            drawRect(838                color = hairline,839                topLeft = Offset(0f, size.height - 1.dp.toPx()),840                size = Size(size.width, 1.dp.toPx()),841            )842        },843    ) {844        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))845        Spacer(Modifier.height(6.dp))846        Row(verticalAlignment = Alignment.CenterVertically, content = content)847    }848}849850/**851 * Text for the ghost PeachScript watermark behind the entry name: the full title,852 * falling back to "P" for a blank title so the hero never renders empty.853 */854internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }855856internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =857    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)858859internal fun shortCredential(credentialId: String): String {860    if (credentialId.length <= 12) return credentialId861    return credentialId.take(6) + "..." + credentialId.takeLast(4)862}863864internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {865    val count = pingCount ?: 0866    val countLabel = "$count total alert" + if (count == 1) "" else "s"867    if (lastPinged == null) return countLabel868    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)869        .format(java.util.Date(lastPinged))870    return "Last triggered: $date · $countLabel"871}872873private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {874    val raw = url875    if (!raw.isNullOrBlank()) {876        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')877        if (cleaned.isNotEmpty()) return cleaned878    }879    return ""880}