EntryDetailScreen.kt

38.9 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectHorizontalDragGestures15import androidx.compose.foundation.gestures.detectVerticalDragGestures16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.Column19import androidx.compose.foundation.layout.Row20import androidx.compose.foundation.layout.Spacer21import androidx.compose.foundation.layout.fillMaxSize22import androidx.compose.foundation.layout.fillMaxHeight23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.offset26import androidx.compose.foundation.layout.padding27import androidx.compose.foundation.layout.navigationBarsPadding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.layout.width31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.shape.CircleShape33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material.icons.rounded.Add36import androidx.compose.material.icons.rounded.Check37import androidx.compose.material.icons.rounded.ContentCopy38import androidx.compose.material.icons.rounded.Search39import androidx.compose.material.icons.rounded.Settings40import androidx.compose.foundation.verticalScroll41import androidx.compose.material.icons.Icons42import androidx.compose.material.icons.automirrored.rounded.ArrowBack43import androidx.compose.material.icons.automirrored.rounded.ArrowForward44import androidx.compose.material.icons.automirrored.outlined.Notes45import androidx.compose.material.icons.outlined.DeleteOutline46import androidx.compose.material.icons.outlined.Lock47import androidx.compose.material3.Icon48import androidx.compose.material3.Text49import androidx.compose.runtime.Composable50import androidx.compose.runtime.getValue51import androidx.compose.runtime.mutableStateOf52import androidx.compose.runtime.produceState53import androidx.compose.runtime.remember54import androidx.compose.runtime.setValue55import androidx.compose.ui.Alignment56import androidx.compose.ui.Modifier57import androidx.compose.ui.draw.clip58import androidx.compose.ui.draw.clipToBounds59import androidx.compose.ui.draw.drawBehind60import androidx.compose.ui.geometry.Offset61import androidx.compose.ui.geometry.Size62import androidx.compose.ui.graphics.Brush63import androidx.compose.ui.graphics.Color64import androidx.compose.ui.graphics.SolidColor65import androidx.compose.ui.graphics.graphicsLayer66import androidx.compose.ui.platform.LocalContext67import androidx.compose.ui.input.pointer.pointerInput68import androidx.compose.ui.text.TextStyle69import androidx.compose.ui.text.font.FontStyle70import androidx.compose.ui.text.style.TextOverflow71import androidx.compose.ui.unit.dp72import androidx.compose.ui.unit.em73import androidx.compose.ui.unit.sp74import androidx.compose.runtime.collectAsState75import androidx.fragment.app.FragmentActivity76import androidx.hilt.navigation.compose.hiltViewModel77import com.peachpasswords.shared.detectHomograph78import com.peachpasswords.ui.PeachNav79import com.peachpasswords.ui.components.CharEnterText80import com.peachpasswords.ui.components.CopyButton81import com.peachpasswords.ui.components.PeachEmptyOrEndState82import com.peachpasswords.ui.components.PeachMark83import com.peachpasswords.ui.components.PeachTopBar84import com.peachpasswords.ui.components.SecureFlagEffect85import com.peachpasswords.ui.components.TOTPRing86import com.peachpasswords.ui.lock.Biometrics87import com.peachpasswords.ui.theme.LocalPeachAccent88import com.peachpasswords.ui.theme.LocalReduceMotion89import com.peachpasswords.ui.theme.PeachSpacing90import com.peachpasswords.ui.theme.peachColors91import com.peachpasswords.ui.theme.PeachType92import com.peachpasswords.ui.theme.ShellBackground93import com.peachpasswords.vault.TOTPGenerator94import com.peachpasswords.vault.hasTOTP95import com.peachpasswords.vault.hasPasskey96import com.peachpasswords.vault.isBreach97import com.peachpasswords.vault.isCard98import com.peachpasswords.vault.isNote99import com.peachpasswords.vault.isPasskeyOnly100101@Composable102fun EntryDetailScreen(103    id: String,104    onNavigate: (PeachNav) -> Unit,105    // Null when this entry is the post-unlock landing surface (C1: entry deck106    // is primary, not a child of the vault list) — there's genuinely nothing107    // to go "back" to, so the top chrome shows a lock affordance instead.108    onBack: (() -> Unit)?,109    modifier: Modifier = Modifier,110    vm: EntryDetailViewModel = hiltViewModel(),111) {112    val entry by vm.entry.collectAsState()113    val activeEntries by vm.activeEntries.collectAsState()114    val identitySuggestions by vm.identitySuggestions.collectAsState()115    val allTags by vm.allTags.collectAsState()116    val isPro by vm.isPro.collectAsState()117    val cvcRevealed by vm.cvcRevealed.collectAsState()118    var cardRevealed by remember { mutableStateOf(false) }119    val copiedField by vm.copiedField.collectAsState()120    val canaryMessage by vm.canaryMessage.collectAsState()121    val canaryTesting by vm.canaryTesting.collectAsState()122    val context = LocalContext.current123    val accent = LocalPeachAccent.current124    var confirmingDelete by remember { mutableStateOf(false) }125    var generatorOpen by remember { mutableStateOf(false) }126    var historyExpanded by remember { mutableStateOf(false) }127128    SecureFlagEffect()129130    val e = entry131    ShellBackground(modifier) {132        Box(133            Modifier.fillMaxSize().statusBarsPadding()134                // C1: swipe left/right between deck entries, mirroring the135                // prev/next arrows in EntryDetailSearchStrip. Resolved on136                // drag end (not per-event) so an in-progress vertical scroll137                // gesture on the content below can't accidentally trigger it.138                .pointerInput(vm) {139                    var dragAccum = 0f140                    detectHorizontalDragGestures(141                        onDragStart = { dragAccum = 0f },142                        onDragEnd = {143                            when {144                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)145                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)146                            }147                            dragAccum = 0f148                        },149                        onDragCancel = { dragAccum = 0f },150                    ) { _, dragAmount -> dragAccum += dragAmount }151                },152        ) {153            Column(154                Modifier.fillMaxSize()155                    .padding(bottom = 100.dp)156                    .verticalScroll(rememberScrollState())157                    .padding(horizontal = PeachSpacing.xl),158            ) {159                EntryDetailTopBar(160                    entry = e,161                    confirmingDelete = confirmingDelete,162                    onBack = onBack,163                    onLock = vm::lock,164                    onDelete = {165                        if (confirmingDelete) {166                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })167                        } else {168                            confirmingDelete = true169                        }170                    },171                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },172                    onSettings = { onNavigate(PeachNav.Settings) },173                    onOpenNotes = { onNavigate(PeachNav.VaultNotes) },174                )175                Spacer(Modifier.height(PeachSpacing.lg))176177                if (e == null) {178                    PeachEmptyOrEndState(179                        message = "entry not found",180                        detail = "It may have been moved or deleted.",181                        modifier = Modifier.padding(top = PeachSpacing.xxl),182                    )183                    return@Column184                }185186                // D11 — breach banner strip when this entry's password is known-compromised.187                if (e.isBreach()) {188                    Row(189                        Modifier.fillMaxWidth()190                            .background(peachColors.BreachBg)191                            .padding(horizontal = 16.dp, vertical = 8.dp),192                        verticalAlignment = Alignment.CenterVertically,193                    ) {194                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)195                        Spacer(Modifier.width(10.dp))196                        Text(197                            "Password found in a data breach — change it now",198                            color = peachColors.Breach, style = PeachType.labelMono,199                            modifier = Modifier.weight(1f),200                        )201                        Text(202                            "Change password",203                            color = accent, style = PeachType.labelMono,204                            modifier = Modifier.clip(RoundedCornerShape(6.dp))205                                .clickable { generatorOpen = true }206                                .padding(horizontal = 6.dp, vertical = 4.dp),207                        )208                    }209                }210                if (entryHasHomographWarning(e)) {211                    HomographWarning()212                }213214                // Hero — ghost PeachScript watermark behind the entry name. The full215                // title is drawn (not just its first letter); a long title overflows216                // and clips naturally, which is the intended watermark behavior.217                Box(Modifier.fillMaxWidth().clipToBounds()) {218                    Text(219                        text = ghostWatermarkText(e.title),220                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),221                        color = peachColors.T0.copy(alpha = 0.03f),222                        maxLines = 1,223                        softWrap = false,224                        overflow = TextOverflow.Clip,225                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),226                    )227                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {228                        EditableTitleHero(229                            title = e.title,230                            onSave = { vm.updateField("title", it) },231                        )232                    }233                }234                Spacer(Modifier.height(PeachSpacing.lg))235236                if (e.isCard()) {237                    CardDetailFields(238                        entry = e,239                        cardRevealed = cardRevealed,240                        cvcRevealed = cvcRevealed,241                        copiedField = copiedField,242                        onToggleCardReveal = { cardRevealed = !cardRevealed },243                        onRevealCvc = {244                            val activity = context as? FragmentActivity245                            if (activity != null && Biometrics.isAvailable(context)) {246                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)247                            } else vm.revealCvc()248                        },249                        onMaskCvc = vm::maskCvc,250                        onCopy = vm::copy,251                        onSaveCard = vm::updateCard,252                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },253                    )254                } else {255                    if (!e.isNote()) {256                        EditableFieldRow(257                            label = "url", field = "url", value = e.url.orEmpty(),258                            copied = copiedField == "url",259                            onCopy = { vm.copy("url", e.url) },260                            onSave = { vm.updateField("url", it) },261                            placeholder = "add url",262                        )263                    }264                    // Browser distinguishes USERNAME and EMAIL as separate265                    // optional fields, with a "+ add X" link for whichever the266                    // entry doesn't have yet — an entry can carry both. This267                    // used to just omit the row entirely for a blank field,268                    // with no way to add it from the detail screen.269                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }270                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }271                    if (showUsernameField) {272                        EditableFieldRow(273                            label = "username", field = "username", value = e.username.orEmpty(),274                            copied = copiedField == "username",275                            onCopy = { vm.copy("username", e.username) },276                            onSave = { vm.updateField("username", it) },277                            suggestions = identitySuggestions,278                            placeholder = "add username",279                        )280                    }281                    if (showEmailField) {282                        EditableFieldRow(283                            label = "email", field = "email", value = e.email.orEmpty(),284                            copied = copiedField == "email",285                            onCopy = { vm.copy("email", e.email) },286                            onSave = { vm.updateField("email", it) },287                            suggestions = identitySuggestions,288                            placeholder = "add email",289                        )290                    }291                    if (!showUsernameField || !showEmailField) {292                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {293                            if (!showUsernameField) {294                                Text(295                                    "+ add username", color = accent, style = PeachType.labelMono,296                                    modifier = Modifier297                                        .clip(RoundedCornerShape(6.dp))298                                        .clickable { showUsernameField = true }299                                        .padding(vertical = 6.dp),300                                )301                            }302                            if (!showEmailField) {303                                Text(304                                    "+ add email", color = accent, style = PeachType.labelMono,305                                    modifier = Modifier306                                        .clip(RoundedCornerShape(6.dp))307                                        .clickable { showEmailField = true }308                                        .padding(vertical = 6.dp),309                                )310                            }311                        }312                    }313                    AdditionalUrlRows(314                        urls = e.additionalUrls.orEmpty(),315                        copiedField = copiedField,316                        onCopy = { key, value -> vm.copy(key, value) },317                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },318                        onAdd = vm::addAdditionalUrl,319                    )320                    if (e.hasPasskey()) {321                        PasskeyDetailSection(e)322                    }323                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->324                        EditablePasswordRow(325                            value = pw,326                            copied = copiedField == "password",327                            entryTitle = e.title,328                            onCopy = { vm.copy("password", pw) },329                            onSave = { vm.updateField("password", it) },330                            onGenerate = { generatorOpen = true },331                        )332                    }333                    val history = e.passwordHistory.orEmpty()334                    if (history.isNotEmpty()) {335                        PasswordHistoryToggle(336                            count = history.size,337                            expanded = historyExpanded,338                            onToggle = { historyExpanded = !historyExpanded },339                        )340                        AnimatedVisibility(visible = historyExpanded) {341                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {342                                PasswordHistoryList(343                                    history = history,344                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },345                                    onCopy = { oldPw -> vm.copy("password", oldPw) },346                                )347                            }348                        }349                    }350                    if (e.hasTOTP()) {351                        Spacer(Modifier.height(4.dp))352                        TotpBandEditable(353                            totpField = e.totp!!,354                            copied = copiedField == "totp",355                            onCopy = vm::copyTotp,356                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },357                        )358                    }359                }360                e.notes?.takeIf { it.isNotBlank() }?.let { v ->361                    EditableFieldRow(362                        label = "notes", field = "notes", value = v,363                        copied = copiedField == "notes",364                        onCopy = { vm.copy("notes", v) },365                        onSave = { vm.updateField("notes", it) },366                        mono = false, singleLine = false,367                    )368                }369                DetailTagRow(370                    tags = e.tags.orEmpty(),371                    allTags = allTags,372                    onAddTag = vm::addTag,373                    onRemoveTag = vm::removeTag,374                )375                if (!e.isNote() && !e.isCard()) {376                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),377                    // unless a canary is already configured for this entry — every378                    // entry showing an "Off" canary block permanently was clutter379                    // most people never touch.380                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }381                    if (advancedExpanded) {382                        CanaryDetailSection(383                            entryId = e.id,384                            modifiedAt = e.modifiedAt,385                            canary = e.canary,386                            isPro = isPro,387                            message = canaryMessage,388                            testing = canaryTesting,389                            onSave = vm::saveCanary,390                            onTest = vm::testCanary,391                        )392                    } else {393                        Text(394                            "+ advanced",395                            color = LocalPeachAccent.current,396                            style = PeachType.labelMono,397                            modifier = Modifier398                                .clip(RoundedCornerShape(6.dp))399                                .clickable { advancedExpanded = true }400                                .padding(vertical = 6.dp),401                        )402                    }403                }404405                Spacer(Modifier.height(PeachSpacing.xxl))406            }407408            if (e != null) {409                EntryDetailSearchStrip(410                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },411                    totalEntries = activeEntries.size,412                    onOpenList = { onNavigate(PeachNav.VaultList) },413                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },414                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },415                    modifier = Modifier.align(Alignment.BottomCenter),416                )417            }418419            if (generatorOpen) {420                GeneratorSheet(421                    copied = copiedField == "genDraft",422                    onCopy = { draft -> vm.copy("genDraft", draft) },423                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },424                    onDismiss = { generatorOpen = false },425                )426            }427        }428    }429}430431@Composable432private fun EntryDetailTopBar(433    entry: com.peachpasswords.vault.VaultEntry?,434    confirmingDelete: Boolean,435    onBack: (() -> Unit)?,436    onLock: () -> Unit,437    onDelete: () -> Unit,438    onAddEntry: () -> Unit,439    onSettings: () -> Unit,440    onOpenNotes: () -> Unit,441) {442    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }443    PeachTopBar(444        title = when {445            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"446            // Deck root (no back target) mirrors the browser's persistent447            // header, which always shows the "peach" wordmark — never the448            // current entry's identity — leaving PeachMark in `leading` below449            // to complete the wordmark pairing (see LockScreen's same pattern).450            onBack == null -> "peach"451            entry == null -> "entry"452            domain.isNotEmpty() -> domain453            else -> entry.title454        },455        leading = {456            if (onBack != null) {457                Icon(458                    Icons.AutoMirrored.Rounded.ArrowBack,459                    contentDescription = "back",460                    tint = peachColors.T1,461                    modifier = Modifier462                        .size(40.dp)463                        .clip(CircleShape)464                        .clickable(onClick = onBack)465                        .padding(PeachSpacing.sm),466                )467            } else {468                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))469            }470            // The favicon chip used to sit here, but it's the one loud,471            // colorful element in an otherwise quiet/muted bar (matching the472            // browser's persistent header, which carries no per-entry color473            // at all) — and it's redundant with the entry's own hero section474            // just below, which already shows its identity clearly.475        },476        actions = {477            // This entry is the deck root (no back target) — it's the closest478            // thing this app has to a "home" screen, so it's where the479            // persistent chrome actions live now, mirroring the browser's480            // peach-chrome header exactly: Notes, Settings, New entry, then481            // Lock last/trailing (peach-browser/src/popup/App.tsx ~1347-1408).482            // Delete has no browser-chrome equivalent (it lives in the entry's483            // own view there) — kept here, grouped with the other484            // content-mutating actions just before the terminal Lock action.485            if (onBack == null) {486                Icon(487                    Icons.AutoMirrored.Outlined.Notes,488                    contentDescription = "Notes",489                    tint = peachColors.T1,490                    modifier = Modifier491                        .size(40.dp)492                        .clip(CircleShape)493                        .clickable(onClick = onOpenNotes)494                        .padding(PeachSpacing.sm),495                )496                Icon(497                    Icons.Rounded.Settings,498                    contentDescription = "settings",499                    tint = peachColors.T1,500                    modifier = Modifier501                        .size(40.dp)502                        .clip(CircleShape)503                        .clickable(onClick = onSettings)504                        .padding(PeachSpacing.sm),505                )506                Icon(507                    Icons.Rounded.Add,508                    contentDescription = "new entry",509                    tint = peachColors.T1,510                    modifier = Modifier511                        .size(40.dp)512                        .clip(CircleShape)513                        .clickable(onClick = onAddEntry)514                        .padding(PeachSpacing.sm),515                )516            }517            if (entry != null) {518                Icon(519                    Icons.Outlined.DeleteOutline,520                    contentDescription = "delete",521                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,522                    modifier = Modifier523                        .size(40.dp)524                        .clip(CircleShape)525                        .clickable(onClick = onDelete)526                        .padding(PeachSpacing.sm),527                )528            }529            if (onBack == null) {530                Icon(531                    Icons.Outlined.Lock,532                    contentDescription = "Lock vault",533                    tint = peachColors.T1,534                    modifier = Modifier535                        .size(40.dp)536                        .clip(CircleShape)537                        .clickable(onClick = onLock)538                        .padding(PeachSpacing.sm),539                )540            }541        },542    )543}544545@Composable546private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {547    val accent = LocalPeachAccent.current548    var editing by remember(title) { mutableStateOf(false) }549    var draft by remember(title) { mutableStateOf(title) }550    if (!editing) {551        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {552            CharEnterText(553                text = title,554                style = PeachType.entryNameHero.copy(color = peachColors.T0),555            )556        }557        return558    }559    Column(Modifier.fillMaxWidth()) {560        BasicTextField(561            value = draft,562            onValueChange = { draft = it },563            singleLine = true,564            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),565            cursorBrush = SolidColor(accent),566            modifier = Modifier.fillMaxWidth().drawBehind {567                drawLine(568                    color = accent,569                    start = Offset(0f, size.height),570                    end = Offset(size.width, size.height),571                    strokeWidth = 1.dp.toPx(),572                )573            },574        )575        Spacer(Modifier.height(8.dp))576        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {577            Text(578                "✓ save", color = accent, style = PeachType.labelMono,579                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {580                    onSave(draft)581                    editing = false582                }.padding(vertical = 4.dp),583            )584            Text(585                "✗", color = peachColors.T2, style = PeachType.labelMono,586                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {587                    draft = title588                    editing = false589                }.padding(horizontal = 6.dp, vertical = 4.dp),590            )591        }592    }593}594595@Composable596private fun HomographWarning() {597    Row(598        Modifier.fillMaxWidth()599            .padding(top = 8.dp)600            .clip(RoundedCornerShape(8.dp))601            .background(peachColors.BreachBg)602            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))603            .padding(horizontal = 12.dp, vertical = 9.dp),604        verticalAlignment = Alignment.CenterVertically,605    ) {606        Text("!", color = peachColors.Breach, style = PeachType.labelMono)607        Spacer(Modifier.width(10.dp))608        Text(609            "This URL contains non-ASCII or punycode characters.",610            color = peachColors.Breach,611            style = PeachType.labelMono,612            modifier = Modifier.weight(1f),613        )614    }615}616617@Composable618private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {619    val passkey = entry.passkey ?: return620    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {621        Column(Modifier.weight(1f)) {622            Text(623                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,624                color = peachColors.T0,625                style = PeachType.body,626            )627            Text(628                listOfNotNull(629                    passkey.rpId,630                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,631                ).joinToString(" · "),632                color = peachColors.T2,633                style = PeachType.labelMono,634            )635            Text(636                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",637                color = peachColors.T2,638                style = PeachType.labelMono,639                modifier = Modifier.padding(top = 3.dp),640            )641        }642    }643}644645@Composable646private fun CanaryDetailSection(647    entryId: String,648    modifiedAt: Long,649    canary: com.peachpasswords.vault.Canary?,650    isPro: Boolean,651    message: String?,652    testing: Boolean,653    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,654    onTest: (webhookUrl: String) -> Unit,655) {656    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }657    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }658    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }659    val gated = !isPro && canary == null660    FieldShell("canary alert") {661        Column(Modifier.weight(1f)) {662            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {663                Text(664                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",665                    color = peachColors.T0,666                    style = PeachType.body,667                    modifier = Modifier.weight(1f),668                )669                if (gated) {670                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)671                }672                Text(673                    if (enabled) "on" else "off",674                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,675                    style = PeachType.labelMono,676                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {677                        if (gated) {678                            onSave(true, webhookUrl, label)679                        } else {680                            val next = !enabled681                            enabled = next682                            if (!next) onSave(false, webhookUrl, label)683                        }684                    }.padding(horizontal = 6.dp, vertical = 4.dp),685                )686            }687            Text(688                "Fires a silent webhook when this entry's password or username is copied.",689                color = peachColors.T2,690                style = PeachType.labelMono,691                modifier = Modifier.padding(top = 3.dp),692            )693            if (enabled) {694                Spacer(Modifier.height(8.dp))695                PeachField(696                    label = "webhook url",697                    value = webhookUrl,698                    onChange = { webhookUrl = it },699                )700                Text(701                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",702                    color = peachColors.T2,703                    style = PeachType.labelMono,704                )705                PeachField(706                    label = "label",707                    value = label,708                    onChange = { label = it },709                    mono = false,710                )711                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {712                    Text(713                        "save",714                        color = LocalPeachAccent.current,715                        style = PeachType.labelMono,716                        modifier = Modifier.clip(RoundedCornerShape(6.dp))717                            .clickable { onSave(true, webhookUrl, label) }718                            .padding(vertical = 4.dp),719                    )720                    Text(721                        if (testing) "sending..." else "test ping",722                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,723                        style = PeachType.labelMono,724                        modifier = Modifier.clip(RoundedCornerShape(6.dp))725                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }726                            .padding(vertical = 4.dp),727                    )728                }729            }730            message?.let {731                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))732            }733            if (canary?.lastPinged != null || canary?.pingCount != null) {734                Text(735                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),736                    color = peachColors.T2,737                    style = PeachType.labelMono,738                    modifier = Modifier.padding(top = 3.dp),739                )740            }741        }742    }743}744745@Composable746private fun EntryDetailSearchStrip(747    currentIndex: Int,748    totalEntries: Int,749    onOpenList: () -> Unit,750    onPrevious: () -> Unit,751    onNext: () -> Unit,752    modifier: Modifier = Modifier,753) {754    val accent = LocalPeachAccent.current755    val hasEntries = totalEntries > 0 && currentIndex >= 0756    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"757758    Column(759        modifier760            .fillMaxWidth()761            .navigationBarsPadding()762            .padding(horizontal = 16.dp, vertical = 12.dp),763    ) {764        Row(765            Modifier766                .fillMaxWidth()767                .height(56.dp)768                .clip(RoundedCornerShape(16.dp))769                .background(peachColors.S1)770                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))771                .padding(horizontal = 12.dp),772            verticalAlignment = Alignment.CenterVertically,773        ) {774            Row(775                Modifier776                    .weight(1f)777                    .fillMaxHeight()778                    .clickable(onClick = onOpenList)779                    .pointerInput(onOpenList) {780                        var opened = false781                        detectVerticalDragGestures(782                            onDragEnd = { opened = false },783                            onDragCancel = { opened = false },784                        ) { _, dragAmount ->785                            if (!opened && dragAmount < -4f) {786                                opened = true787                                onOpenList()788                            }789                        }790                    },791                verticalAlignment = Alignment.CenterVertically,792            ) {793                Icon(794                    Icons.Rounded.Search,795                    contentDescription = null,796                    tint = accent,797                    modifier = Modifier.size(14.dp),798                )799                Spacer(Modifier.width(8.dp))800                Text(801                    "search vault...",802                    color = peachColors.T2,803                    style = PeachType.searchInput,804                    modifier = Modifier.weight(1f),805                )806                Text(807                    counter,808                    color = peachColors.T2,809                    style = PeachType.labelMono,810                )811            }812            Spacer(Modifier.width(8.dp))813            Icon(814                Icons.AutoMirrored.Rounded.ArrowBack,815                contentDescription = "Previous entry",816                tint = if (hasEntries) peachColors.T0 else peachColors.T2,817                modifier = Modifier818                    .size(38.dp)819                    .clip(CircleShape)820                    .clickable(enabled = hasEntries, onClick = onPrevious)821                    .padding(9.dp),822            )823            Icon(824                Icons.AutoMirrored.Rounded.ArrowForward,825                contentDescription = "Next entry",826                tint = if (hasEntries) peachColors.T0 else peachColors.T2,827                modifier = Modifier828                    .size(38.dp)829                    .clip(CircleShape)830                    .clickable(enabled = hasEntries, onClick = onNext)831                    .padding(9.dp),832            )833        }834    }835}836837/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */838@Composable839internal fun FieldShell(840    label: String,841    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,842) {843    val hairline = peachColors.T3844    Column(845        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {846            drawRect(847                color = hairline,848                topLeft = Offset(0f, size.height - 1.dp.toPx()),849                size = Size(size.width, 1.dp.toPx()),850            )851        },852    ) {853        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))854        Spacer(Modifier.height(6.dp))855        Row(verticalAlignment = Alignment.CenterVertically, content = content)856    }857}858859/**860 * Text for the ghost PeachScript watermark behind the entry name: the full title,861 * falling back to "P" for a blank title so the hero never renders empty.862 */863internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }864865internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =866    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)867868internal fun shortCredential(credentialId: String): String {869    if (credentialId.length <= 12) return credentialId870    return credentialId.take(6) + "..." + credentialId.takeLast(4)871}872873internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {874    val count = pingCount ?: 0875    val countLabel = "$count total alert" + if (count == 1) "" else "s"876    if (lastPinged == null) return countLabel877    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)878        .format(java.util.Date(lastPinged))879    return "Last triggered: $date · $countLabel"880}881882private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {883    val raw = url884    if (!raw.isNullOrBlank()) {885        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')886        if (cleaned.isNotEmpty()) return cleaned887    }888    return ""889}