EntryDetailScreen.kt

35.4 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.layout.Arrangement16import androidx.compose.foundation.layout.Box17import androidx.compose.foundation.layout.Column18import androidx.compose.foundation.layout.Row19import androidx.compose.foundation.layout.Spacer20import androidx.compose.foundation.layout.fillMaxSize21import androidx.compose.foundation.layout.fillMaxWidth22import androidx.compose.foundation.layout.height23import androidx.compose.foundation.layout.offset24import androidx.compose.foundation.layout.padding25import androidx.compose.foundation.layout.navigationBarsPadding26import androidx.compose.foundation.layout.size27import androidx.compose.foundation.layout.statusBarsPadding28import androidx.compose.foundation.layout.width29import androidx.compose.foundation.rememberScrollState30import androidx.compose.foundation.shape.RoundedCornerShape31import androidx.compose.foundation.text.BasicTextField32import androidx.compose.material.icons.rounded.Check33import androidx.compose.material.icons.rounded.ContentCopy34import androidx.compose.foundation.verticalScroll35import androidx.compose.material.icons.Icons36import androidx.compose.material.icons.automirrored.rounded.ArrowBack37import androidx.compose.material.icons.automirrored.rounded.ArrowForward38import androidx.compose.material3.Icon39import androidx.compose.material3.Text40import androidx.compose.runtime.Composable41import androidx.compose.runtime.getValue42import androidx.compose.runtime.mutableStateOf43import androidx.compose.runtime.produceState44import androidx.compose.runtime.remember45import androidx.compose.runtime.setValue46import androidx.compose.ui.Alignment47import androidx.compose.ui.Modifier48import androidx.compose.ui.draw.clip49import androidx.compose.ui.draw.clipToBounds50import androidx.compose.ui.draw.drawBehind51import androidx.compose.ui.geometry.Offset52import androidx.compose.ui.geometry.Size53import androidx.compose.ui.graphics.Brush54import androidx.compose.ui.graphics.Color55import androidx.compose.ui.graphics.SolidColor56import androidx.compose.ui.graphics.graphicsLayer57import androidx.compose.ui.platform.LocalContext58import androidx.compose.ui.input.pointer.pointerInput59import androidx.compose.ui.text.TextStyle60import androidx.compose.ui.text.font.FontStyle61import androidx.compose.ui.text.style.TextOverflow62import androidx.compose.ui.unit.dp63import androidx.compose.ui.unit.em64import androidx.compose.ui.unit.sp65import androidx.compose.runtime.collectAsState66import androidx.fragment.app.FragmentActivity67import androidx.hilt.navigation.compose.hiltViewModel68import com.peachpasswords.shared.detectHomograph69import com.peachpasswords.ui.PeachNav70import com.peachpasswords.ui.components.CharEnterText71import com.peachpasswords.ui.components.CopyButton72import com.peachpasswords.ui.components.PeachEmptyOrEndState73import com.peachpasswords.ui.components.SecureFlagEffect74import com.peachpasswords.ui.components.TOTPRing75import com.peachpasswords.ui.lock.Biometrics76import com.peachpasswords.ui.theme.LocalPeachAccent77import com.peachpasswords.ui.theme.LocalReduceMotion78import com.peachpasswords.ui.theme.PeachSpacing79import com.peachpasswords.ui.theme.PeachRadius80import com.peachpasswords.ui.theme.peachColors81import com.peachpasswords.ui.theme.PeachType82import com.peachpasswords.ui.theme.ShellBackground83import com.peachpasswords.vault.TOTPGenerator84import com.peachpasswords.vault.hasTOTP85import com.peachpasswords.vault.hasPasskey86import com.peachpasswords.vault.isBreach87import com.peachpasswords.vault.isCard88import com.peachpasswords.vault.isNote89import com.peachpasswords.vault.isPasskeyOnly9091@Composable92fun EntryDetailScreen(93    id: String,94    onNavigate: (PeachNav) -> Unit,95    // Null when this entry is the post-unlock landing surface (C1: entry deck96    // is primary, not a child of the vault list) — there's genuinely nothing97    // to go "back" to, so the top chrome shows a lock affordance instead.98    onBack: (() -> Unit)?,99    modifier: Modifier = Modifier,100    vm: EntryDetailViewModel = hiltViewModel(),101) {102    SecureFlagEffect()103104    val entry by vm.entry.collectAsState()105    val activeEntries by vm.activeEntries.collectAsState()106    val identitySuggestions by vm.identitySuggestions.collectAsState()107    val allTags by vm.allTags.collectAsState()108    val isPro by vm.isPro.collectAsState()109    val cvcRevealed by vm.cvcRevealed.collectAsState()110    var cardRevealed by remember { mutableStateOf(false) }111    val copiedField by vm.copiedField.collectAsState()112    val canaryMessage by vm.canaryMessage.collectAsState()113    val canaryTesting by vm.canaryTesting.collectAsState()114    val context = LocalContext.current115    val accent = LocalPeachAccent.current116    var confirmingDelete by remember { mutableStateOf(false) }117    var generatorOpen by remember { mutableStateOf(false) }118    var historyExpanded by remember { mutableStateOf(false) }119    var urlEditing by remember { mutableStateOf(false) }120121    val e = entry122    ShellBackground(modifier) {123        Box(124            Modifier.fillMaxSize().statusBarsPadding()125                // C1: swipe left/right between deck entries, mirroring the126                // prev/next arrows in EntryDetailSearchStrip. Resolved on127                // drag end (not per-event) so an in-progress vertical scroll128                // gesture on the content below can't accidentally trigger it.129                .pointerInput(vm) {130                    var dragAccum = 0f131                    detectHorizontalDragGestures(132                        onDragStart = { dragAccum = 0f },133                        onDragEnd = {134                            when {135                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)136                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)137                            }138                            dragAccum = 0f139                        },140                        onDragCancel = { dragAccum = 0f },141                    ) { _, dragAmount -> dragAccum += dragAmount }142                },143        ) {144            Column(145                Modifier.fillMaxSize()146                    .padding(bottom = 172.dp)147                    .verticalScroll(rememberScrollState())148                    .padding(horizontal = PeachSpacing.xl),149            ) {150                PeachVaultIdentity("vault") { onNavigate(PeachNav.VaultList) }151                Spacer(Modifier.height(PeachSpacing.xxl))152153                if (e == null) {154                    PeachEmptyOrEndState(155                        message = "entry not found",156                        detail = "It may have been moved or deleted.",157                        modifier = Modifier.padding(top = PeachSpacing.xxl),158                    )159                    return@Column160                }161162                // D11 — breach banner strip when this entry's password is known-compromised.163                if (e.isBreach()) {164                    Row(165                        Modifier.fillMaxWidth()166                            .background(peachColors.BreachBg)167                            .padding(horizontal = 16.dp, vertical = 8.dp),168                        verticalAlignment = Alignment.CenterVertically,169                    ) {170                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)171                        Spacer(Modifier.width(10.dp))172                        Text(173                            "Password found in a data breach — change it now",174                            color = peachColors.Breach, style = PeachType.labelMono,175                            modifier = Modifier.weight(1f),176                        )177                        Text(178                            "Change password",179                            color = accent, style = PeachType.labelMono,180                            modifier = Modifier.clip(RoundedCornerShape(6.dp))181                                .clickable { generatorOpen = true }182                                .padding(horizontal = 6.dp, vertical = 4.dp),183                        )184                    }185                }186                if (entryHasHomographWarning(e)) {187                    HomographWarning()188                }189190                // Hero — ghost PeachScript watermark behind the entry name. The full191                // title is drawn (not just its first letter); a long title overflows192                // and clips naturally, which is the intended watermark behavior.193                Box(Modifier.fillMaxWidth().clipToBounds()) {194                    Text(195                        text = ghostWatermarkText(e.title),196                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),197                        color = peachColors.T0.copy(alpha = 0.03f),198                        maxLines = 1,199                        softWrap = false,200                        overflow = TextOverflow.Clip,201                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),202                    )203                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {204                        EditableTitleHero(205                            title = e.title,206                            onSave = { vm.updateField("title", it) },207                        )208                    }209                }210                Spacer(Modifier.height(PeachSpacing.lg))211212                if (e.isCard()) {213                    CardDetailFields(214                        entry = e,215                        cardRevealed = cardRevealed,216                        cvcRevealed = cvcRevealed,217                        copiedField = copiedField,218                        onToggleCardReveal = { cardRevealed = !cardRevealed },219                        onRevealCvc = {220                            val activity = context as? FragmentActivity221                            if (activity != null && Biometrics.isAvailable(context)) {222                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)223                            } else vm.revealCvc()224                        },225                        onMaskCvc = vm::maskCvc,226                        onCopy = vm::copy,227                        onSaveCard = vm::updateCard,228                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },229                    )230                } else {231                    if (!e.isNote()) {232                        EditableFieldRow(233                            label = "url", field = "url", value = e.url.orEmpty(),234                            copied = copiedField == "url",235                            onCopy = { vm.copy("url", e.url) },236                            onSave = { vm.updateField("url", it) },237                            placeholder = "add url",238                            onEditingChange = { urlEditing = it },239                        )240                    }241                    // Browser distinguishes USERNAME and EMAIL as separate242                    // optional fields, with a "+ add X" link for whichever the243                    // entry doesn't have yet — an entry can carry both. This244                    // used to just omit the row entirely for a blank field,245                    // with no way to add it from the detail screen.246                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }247                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }248                    if (showUsernameField) {249                        EditableFieldRow(250                            label = "username", field = "username", value = e.username.orEmpty(),251                            copied = copiedField == "username",252                            onCopy = { vm.copy("username", e.username) },253                            onSave = { vm.updateField("username", it) },254                            suggestions = identitySuggestions,255                            placeholder = "add username",256                        )257                    }258                    if (showEmailField) {259                        EditableFieldRow(260                            label = "email", field = "email", value = e.email.orEmpty(),261                            copied = copiedField == "email",262                            onCopy = { vm.copy("email", e.email) },263                            onSave = { vm.updateField("email", it) },264                            suggestions = identitySuggestions,265                            placeholder = "add email",266                        )267                    }268                    if (!showUsernameField || !showEmailField) {269                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {270                            if (!showUsernameField) {271                                Text(272                                    "+ add username", color = accent, style = PeachType.labelMono,273                                    modifier = Modifier274                                        .clip(RoundedCornerShape(6.dp))275                                        .clickable { showUsernameField = true }276                                        .padding(vertical = 6.dp),277                                )278                            }279                            if (!showEmailField) {280                                Text(281                                    "+ add email", color = accent, style = PeachType.labelMono,282                                    modifier = Modifier283                                        .clip(RoundedCornerShape(6.dp))284                                        .clickable { showEmailField = true }285                                        .padding(vertical = 6.dp),286                                )287                            }288                        }289                    }290                    AdditionalUrlRows(291                        urls = e.additionalUrls.orEmpty(),292                        copiedField = copiedField,293                        onCopy = { key, value -> vm.copy(key, value) },294                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },295                        onAdd = vm::addAdditionalUrl,296                        showAdd = urlEditing,297                    )298                    if (e.hasPasskey()) {299                        PasskeyDetailSection(e)300                    }301                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->302                        EditablePasswordRow(303                            value = pw,304                            copied = copiedField == "password",305                            entryTitle = e.title,306                            onCopy = { vm.copy("password", pw) },307                            onSave = { vm.updateField("password", it) },308                            onGenerate = { generatorOpen = true },309                        )310                    }311                    // Always show the history affordance (browser parity) — visible312                    // even before any password change, expanding to an empty-state313                    // explainer so the feature is discoverable rather than hidden314                    // until history already exists.315                    val history = e.passwordHistory.orEmpty()316                    PasswordHistoryToggle(317                        count = history.size,318                        expanded = historyExpanded,319                        onToggle = { historyExpanded = !historyExpanded },320                    )321                    AnimatedVisibility(visible = historyExpanded) {322                        Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {323                            PasswordHistoryList(324                                history = history,325                                onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },326                                onCopy = { oldPw -> vm.copy("password", oldPw) },327                            )328                        }329                    }330                    if (e.hasTOTP()) {331                        Spacer(Modifier.height(4.dp))332                        TotpBandEditable(333                            totpField = e.totp!!,334                            copied = copiedField == "totp",335                            onCopy = vm::copyTotp,336                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },337                        )338                    }339                }340                e.notes?.takeIf { it.isNotBlank() }?.let { v ->341                    // A note authored in the browser is HTML: shown rendered,342                    // edited as markup, saved back as the same tags.343                    val notesAreHtml = isNoteHtml(v)344                    EditableFieldRow(345                        label = "notes", field = "notes", value = v,346                        copied = copiedField == "notes",347                        onCopy = { vm.copy("notes", notePlainText(v)) },348                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },349                        mono = false, singleLine = false,350                        displayText = noteRendered(v),351                        draftText = noteEditorText(v),352                    )353                }354                EntryDetailActions(355                    confirmingDelete = confirmingDelete,356                    onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },357                    onDelete = {358                        if (confirmingDelete) {359                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })360                        } else {361                            confirmingDelete = true362                        }363                    },364                )365                Spacer(Modifier.height(PeachSpacing.md))366                DetailTagRow(367                    tags = e.tags.orEmpty(),368                    allTags = allTags,369                    onAddTag = vm::addTag,370                    onRemoveTag = vm::removeTag,371                )372                if (!e.isNote() && !e.isCard()) {373                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),374                    // unless a canary is already configured for this entry — every375                    // entry showing an "Off" canary block permanently was clutter376                    // most people never touch.377                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }378                    if (advancedExpanded) {379                        CanaryDetailSection(380                            entryId = e.id,381                            modifiedAt = e.modifiedAt,382                            canary = e.canary,383                            isPro = isPro,384                            message = canaryMessage,385                            testing = canaryTesting,386                            onSave = vm::saveCanary,387                            onTest = vm::testCanary,388                        )389                    } else {390                        FieldShell("advanced") {391                            Text(392                                "Canary alert and protection",393                                color = peachColors.T2,394                                style = PeachType.bodyMono,395                                modifier = Modifier.weight(1f),396                            )397                            Text(398                                "open", color = LocalPeachAccent.current, style = PeachType.labelMono,399                                modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),400                            )401                        }402                    }403                }404405                Spacer(Modifier.height(PeachSpacing.xxl))406            }407408            if (e != null) {409                EntryDetailBottomControls(410                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },411                    totalEntries = activeEntries.size,412                    onOpenList = { onNavigate(PeachNav.VaultList) },413                    onSearch = { onNavigate(PeachNav.VaultSearch) },414                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },415                    onOpenNotes = { onNavigate(PeachNav.Notes) },416                    onSettings = { onNavigate(PeachNav.Settings) },417                    onLock = vm::lock,418                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },419                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },420                    modifier = Modifier.align(Alignment.BottomCenter),421                )422            }423424            if (generatorOpen) {425                GeneratorSheet(426                    copied = copiedField == "genDraft",427                    onCopy = { draft -> vm.copy("genDraft", draft) },428                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },429                    onDismiss = { generatorOpen = false },430                )431            }432        }433    }434}435436@Composable437private fun EntryDetailActions(438    confirmingDelete: Boolean,439    onEdit: () -> Unit,440    onDelete: () -> Unit,441) {442    val accent = LocalPeachAccent.current443    Row(444        Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),445        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),446    ) {447        DetailActionButton(448            label = "edit",449            color = peachColors.Bg,450            background = accent,451            border = accent,452            onClick = onEdit,453            modifier = Modifier.weight(1f),454        )455        DetailActionButton(456            label = if (confirmingDelete) "confirm trash" else "move to trash",457            color = if (confirmingDelete) peachColors.Breach else accent,458            background = Color.Transparent,459            border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,460            onClick = onDelete,461            modifier = Modifier.weight(1f),462        )463    }464}465466@Composable467private fun DetailActionButton(468    label: String,469    color: Color,470    background: Color,471    border: Color,472    onClick: () -> Unit,473    modifier: Modifier = Modifier,474) {475    Box(476        modifier.height(52.dp)477            .clip(RoundedCornerShape(PeachRadius.Button))478            .background(background)479            .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))480            .clickable(onClick = onClick),481        contentAlignment = Alignment.Center,482    ) {483        Text(label, color = color, style = PeachType.bodyMono)484    }485}486487@Composable488private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {489    val accent = LocalPeachAccent.current490    var editing by remember(title) { mutableStateOf(false) }491    var draft by remember(title) { mutableStateOf(title) }492    if (!editing) {493        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {494            CharEnterText(495                text = title,496                style = PeachType.entryNameHero.copy(color = peachColors.T0),497            )498        }499        return500    }501    Column(Modifier.fillMaxWidth()) {502        BasicTextField(503            value = draft,504            onValueChange = { draft = it },505            singleLine = true,506            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),507            cursorBrush = SolidColor(accent),508            modifier = Modifier.fillMaxWidth().drawBehind {509                drawLine(510                    color = accent,511                    start = Offset(0f, size.height),512                    end = Offset(size.width, size.height),513                    strokeWidth = 1.dp.toPx(),514                )515            },516        )517        Spacer(Modifier.height(8.dp))518        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {519            Text(520                "✓ save", color = accent, style = PeachType.labelMono,521                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {522                    onSave(draft)523                    editing = false524                }.padding(vertical = 4.dp),525            )526            Text(527                "✗", color = peachColors.T2, style = PeachType.labelMono,528                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {529                    draft = title530                    editing = false531                }.padding(horizontal = 6.dp, vertical = 4.dp),532            )533        }534    }535}536537@Composable538private fun HomographWarning() {539    Row(540        Modifier.fillMaxWidth()541            .padding(top = 8.dp)542            .clip(RoundedCornerShape(8.dp))543            .background(peachColors.BreachBg)544            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))545            .padding(horizontal = 12.dp, vertical = 9.dp),546        verticalAlignment = Alignment.CenterVertically,547    ) {548        Text("!", color = peachColors.Breach, style = PeachType.labelMono)549        Spacer(Modifier.width(10.dp))550        Text(551            "This URL contains non-ASCII or punycode characters.",552            color = peachColors.Breach,553            style = PeachType.labelMono,554            modifier = Modifier.weight(1f),555        )556    }557}558559@Composable560private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {561    val passkey = entry.passkey ?: return562    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {563        Column(Modifier.weight(1f)) {564            Text(565                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,566                color = peachColors.T0,567                style = PeachType.body,568            )569            Text(570                listOfNotNull(571                    passkey.rpId,572                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,573                ).joinToString(" · "),574                color = peachColors.T2,575                style = PeachType.labelMono,576            )577            Text(578                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",579                color = peachColors.T2,580                style = PeachType.labelMono,581                modifier = Modifier.padding(top = 3.dp),582            )583        }584    }585}586587@Composable588private fun CanaryDetailSection(589    entryId: String,590    modifiedAt: Long,591    canary: com.peachpasswords.vault.Canary?,592    isPro: Boolean,593    message: String?,594    testing: Boolean,595    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,596    onTest: (webhookUrl: String) -> Unit,597) {598    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }599    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }600    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }601    val gated = !isPro && canary == null602    FieldShell("canary alert") {603        Column(Modifier.weight(1f)) {604            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {605                Text(606                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",607                    color = peachColors.T0,608                    style = PeachType.body,609                    modifier = Modifier.weight(1f),610                )611                if (gated) {612                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)613                }614                Text(615                    if (enabled) "on" else "off",616                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,617                    style = PeachType.labelMono,618                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {619                        if (gated) {620                            onSave(true, webhookUrl, label)621                        } else {622                            val next = !enabled623                            enabled = next624                            if (!next) onSave(false, webhookUrl, label)625                        }626                    }.padding(horizontal = 6.dp, vertical = 4.dp),627                )628            }629            Text(630                "Fires a silent webhook when this entry's password or username is copied.",631                color = peachColors.T2,632                style = PeachType.labelMono,633                modifier = Modifier.padding(top = 3.dp),634            )635            if (enabled) {636                Spacer(Modifier.height(8.dp))637                PeachField(638                    label = "webhook url",639                    value = webhookUrl,640                    onChange = { webhookUrl = it },641                )642                Text(643                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",644                    color = peachColors.T2,645                    style = PeachType.labelMono,646                )647                PeachField(648                    label = "label",649                    value = label,650                    onChange = { label = it },651                    mono = false,652                )653                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {654                    Text(655                        "save",656                        color = LocalPeachAccent.current,657                        style = PeachType.labelMono,658                        modifier = Modifier.clip(RoundedCornerShape(6.dp))659                            .clickable { onSave(true, webhookUrl, label) }660                            .padding(vertical = 4.dp),661                    )662                    Text(663                        if (testing) "sending..." else "test ping",664                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,665                        style = PeachType.labelMono,666                        modifier = Modifier.clip(RoundedCornerShape(6.dp))667                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }668                            .padding(vertical = 4.dp),669                    )670                }671            }672            message?.let {673                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))674            }675            if (canary?.lastPinged != null || canary?.pingCount != null) {676                Text(677                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),678                    color = peachColors.T2,679                    style = PeachType.labelMono,680                    modifier = Modifier.padding(top = 3.dp),681                )682            }683        }684    }685}686687@Composable688private fun EntryDetailBottomControls(689    currentIndex: Int,690    totalEntries: Int,691    onOpenList: () -> Unit,692    onSearch: () -> Unit,693    onAddEntry: () -> Unit,694    onOpenNotes: () -> Unit,695    onSettings: () -> Unit,696    onLock: () -> Unit,697    onPrevious: () -> Unit,698    onNext: () -> Unit,699    modifier: Modifier = Modifier,700) {701    val accent = LocalPeachAccent.current702    val hasEntries = totalEntries > 0 && currentIndex >= 0703    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"704705    Column(706        modifier707            .fillMaxWidth()708    ) {709        // Gentle fade: scrolling content dissolves into the background710        // instead of hitting a hard opaque edge.711        Box(712            Modifier.fillMaxWidth().height(40.dp)713                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),714        )715        Column(716            Modifier.fillMaxWidth()717                .background(peachColors.Bg)718                .navigationBarsPadding(),719        ) {720            Row(721                Modifier.fillMaxWidth().height(44.dp),722                horizontalArrangement = Arrangement.Center,723                verticalAlignment = Alignment.CenterVertically,724            ) {725                Icon(726                    Icons.AutoMirrored.Rounded.ArrowBack,727                    contentDescription = "Previous entry",728                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,729                    modifier = Modifier730                        .size(36.dp)731                        .clip(RoundedCornerShape(8.dp))732                        .clickable(enabled = hasEntries, onClick = onPrevious)733                        .padding(8.dp),734                )735                Spacer(Modifier.width(PeachSpacing.md))736                Text(counter, color = peachColors.T2, style = PeachType.labelMono)737                Spacer(Modifier.width(PeachSpacing.md))738                Spacer(Modifier.width(4.dp))739                Icon(740                    Icons.AutoMirrored.Rounded.ArrowForward,741                    contentDescription = "Next entry",742                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,743                    modifier = Modifier744                        .size(36.dp)745                        .clip(RoundedCornerShape(8.dp))746                        .clickable(enabled = hasEntries, onClick = onNext)747                        .padding(8.dp),748                )749            }750            PeachDeckBottomBar(751                onSearch = onSearch,752                onAdd = onAddEntry,753                onNotes = onOpenNotes,754                onSettings = onSettings,755                onLock = onLock,756                onSwipeUp = onOpenList,757            )758        }759    }760}761762/** A credential field card: uppercase mono label over its value and local actions. */763@Composable764internal fun FieldShell(765    label: String,766    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,767) {768    Column(769        Modifier.fillMaxWidth()770            .padding(vertical = PeachSpacing.xs)771            .clip(RoundedCornerShape(PeachRadius.StackCard))772            .background(peachColors.SurfaceCard)773            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))774            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),775    ) {776        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))777        Spacer(Modifier.height(6.dp))778        Row(verticalAlignment = Alignment.CenterVertically, content = content)779    }780}781782/**783 * Text for the ghost PeachScript watermark behind the entry name: the full title,784 * falling back to "P" for a blank title so the hero never renders empty.785 */786internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }787788internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =789    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)790791internal fun shortCredential(credentialId: String): String {792    if (credentialId.length <= 12) return credentialId793    return credentialId.take(6) + "..." + credentialId.takeLast(4)794}795796internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {797    val count = pingCount ?: 0798    val countLabel = "$count total alert" + if (count == 1) "" else "s"799    if (lastPinged == null) return countLabel800    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)801        .format(java.util.Date(lastPinged))802    return "Last triggered: $date · $countLabel"803}