EntryDetailScreen.kt

37.1 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                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }232                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }233                    val hasPassword = !e.password.isNullOrBlank()234                    if (!e.isNote()) {235                        CredentialStack {236                            EditableFieldRow(237                                label = "url", field = "url", value = e.url.orEmpty(),238                                copied = copiedField == "url",239                                onCopy = { vm.copy("url", e.url) },240                                onSave = { vm.updateField("url", it) },241                                placeholder = "add url",242                                onEditingChange = { urlEditing = it },243                                stacked = true,244                                divider = showUsernameField || showEmailField || hasPassword,245                            )246                            // Browser distinguishes USERNAME and EMAIL as separate247                            // optional fields, with a "+ add X" link for whichever the248                            // entry doesn't have yet — an entry can carry both.249                            if (showUsernameField) {250                                EditableFieldRow(251                                    label = "username", field = "username", value = e.username.orEmpty(),252                                    copied = copiedField == "username",253                                    onCopy = { vm.copy("username", e.username) },254                                    onSave = { vm.updateField("username", it) },255                                    suggestions = identitySuggestions,256                                    placeholder = "add username",257                                    stacked = true,258                                    divider = showEmailField || hasPassword,259                                )260                            }261                            if (showEmailField) {262                                EditableFieldRow(263                                    label = "email", field = "email", value = e.email.orEmpty(),264                                    copied = copiedField == "email",265                                    onCopy = { vm.copy("email", e.email) },266                                    onSave = { vm.updateField("email", it) },267                                    suggestions = identitySuggestions,268                                    placeholder = "add email",269                                    stacked = true,270                                    divider = hasPassword,271                                )272                            }273                            e.password?.takeIf { it.isNotBlank() }?.let { pw ->274                                EditablePasswordRow(275                                    value = pw,276                                    copied = copiedField == "password",277                                    entryTitle = e.title,278                                    onCopy = { vm.copy("password", pw) },279                                    onSave = { vm.updateField("password", it) },280                                    onGenerate = { generatorOpen = true },281                                    stacked = true,282                                )283                            }284                        }285                    }286                    if (!showUsernameField || !showEmailField) {287                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {288                            if (!showUsernameField) {289                                Text(290                                    "+ add username", color = accent, style = PeachType.labelMono,291                                    modifier = Modifier292                                        .clip(RoundedCornerShape(6.dp))293                                        .clickable { showUsernameField = true }294                                        .padding(vertical = 6.dp),295                                )296                            }297                            if (!showEmailField) {298                                Text(299                                    "+ add email", color = accent, style = PeachType.labelMono,300                                    modifier = Modifier301                                        .clip(RoundedCornerShape(6.dp))302                                        .clickable { showEmailField = true }303                                        .padding(vertical = 6.dp),304                                )305                            }306                        }307                    }308                    AdditionalUrlRows(309                        urls = e.additionalUrls.orEmpty(),310                        copiedField = copiedField,311                        onCopy = { key, value -> vm.copy(key, value) },312                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },313                        onAdd = vm::addAdditionalUrl,314                        showAdd = urlEditing,315                    )316                    if (e.hasPasskey()) {317                        PasskeyDetailSection(e)318                    }319                    // Always show the history affordance (browser parity) — visible320                    // even before any password change, expanding to an empty-state321                    // explainer so the feature is discoverable rather than hidden322                    // until history already exists.323                    val history = e.passwordHistory.orEmpty()324                    PasswordHistoryToggle(325                        count = history.size,326                        expanded = historyExpanded,327                        onToggle = { historyExpanded = !historyExpanded },328                    )329                    AnimatedVisibility(visible = historyExpanded) {330                        Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {331                            PasswordHistoryList(332                                history = history,333                                onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },334                                onCopy = { oldPw -> vm.copy("password", oldPw) },335                            )336                        }337                    }338                    if (e.hasTOTP()) {339                        Spacer(Modifier.height(4.dp))340                        TotpBandEditable(341                            totpField = e.totp!!,342                            copied = copiedField == "totp",343                            onCopy = vm::copyTotp,344                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },345                        )346                    }347                }348                e.notes?.takeIf { it.isNotBlank() }?.let { v ->349                    // A note authored in the browser is HTML: shown rendered,350                    // edited as markup, saved back as the same tags.351                    val notesAreHtml = isNoteHtml(v)352                    EditableFieldRow(353                        label = "notes", field = "notes", value = v,354                        copied = copiedField == "notes",355                        onCopy = { vm.copy("notes", notePlainText(v)) },356                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },357                        mono = false, singleLine = false,358                        displayText = noteRendered(v),359                        draftText = noteEditorText(v),360                    )361                }362                EntryDetailActions(363                    confirmingDelete = confirmingDelete,364                    onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },365                    onDelete = {366                        if (confirmingDelete) {367                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })368                        } else {369                            confirmingDelete = true370                        }371                    },372                )373                Spacer(Modifier.height(PeachSpacing.md))374                DetailTagRow(375                    tags = e.tags.orEmpty(),376                    allTags = allTags,377                    onAddTag = vm::addTag,378                    onRemoveTag = vm::removeTag,379                )380                if (!e.isNote() && !e.isCard()) {381                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),382                    // unless a canary is already configured for this entry — every383                    // entry showing an "Off" canary block permanently was clutter384                    // most people never touch.385                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }386                    if (advancedExpanded) {387                        CanaryDetailSection(388                            entryId = e.id,389                            modifiedAt = e.modifiedAt,390                            canary = e.canary,391                            isPro = isPro,392                            message = canaryMessage,393                            testing = canaryTesting,394                            onSave = vm::saveCanary,395                            onTest = vm::testCanary,396                        )397                    } else {398                        FieldShell("advanced") {399                            Text(400                                "Canary alert and protection",401                                color = peachColors.T2,402                                style = PeachType.bodyMono,403                                modifier = Modifier.weight(1f),404                            )405                            Text(406                                "open", color = LocalPeachAccent.current, style = PeachType.labelMono,407                                modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),408                            )409                        }410                    }411                }412413                Spacer(Modifier.height(PeachSpacing.xxl))414            }415416            if (e != null) {417                EntryDetailBottomControls(418                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },419                    totalEntries = activeEntries.size,420                    onOpenList = { onNavigate(PeachNav.VaultList) },421                    onSearch = { onNavigate(PeachNav.VaultSearch) },422                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },423                    onOpenNotes = { onNavigate(PeachNav.Notes) },424                    onSettings = { onNavigate(PeachNav.Settings) },425                    onLock = vm::lock,426                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },427                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },428                    modifier = Modifier.align(Alignment.BottomCenter),429                )430            }431432            if (generatorOpen) {433                GeneratorSheet(434                    copied = copiedField == "genDraft",435                    onCopy = { draft -> vm.copy("genDraft", draft) },436                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },437                    onDismiss = { generatorOpen = false },438                )439            }440        }441    }442}443444@Composable445private fun EntryDetailActions(446    confirmingDelete: Boolean,447    onEdit: () -> Unit,448    onDelete: () -> Unit,449) {450    val accent = LocalPeachAccent.current451    Row(452        Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),453        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),454    ) {455        DetailActionButton(456            label = "edit",457            color = peachColors.Bg,458            background = accent,459            border = accent,460            onClick = onEdit,461            modifier = Modifier.weight(1f),462        )463        DetailActionButton(464            label = if (confirmingDelete) "confirm trash" else "move to trash",465            color = if (confirmingDelete) peachColors.Breach else accent,466            background = Color.Transparent,467            border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,468            onClick = onDelete,469            modifier = Modifier.weight(1f),470        )471    }472}473474@Composable475private fun DetailActionButton(476    label: String,477    color: Color,478    background: Color,479    border: Color,480    onClick: () -> Unit,481    modifier: Modifier = Modifier,482) {483    Box(484        modifier.height(52.dp)485            .clip(RoundedCornerShape(PeachRadius.Button))486            .background(background)487            .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))488            .clickable(onClick = onClick),489        contentAlignment = Alignment.Center,490    ) {491        Text(label, color = color, style = PeachType.bodyMono)492    }493}494495@Composable496private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {497    val accent = LocalPeachAccent.current498    var editing by remember(title) { mutableStateOf(false) }499    var draft by remember(title) { mutableStateOf(title) }500    if (!editing) {501        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {502            CharEnterText(503                text = title,504                style = PeachType.entryNameHero.copy(color = peachColors.T0),505            )506        }507        return508    }509    Column(Modifier.fillMaxWidth()) {510        BasicTextField(511            value = draft,512            onValueChange = { draft = it },513            singleLine = true,514            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),515            cursorBrush = SolidColor(accent),516            modifier = Modifier.fillMaxWidth().drawBehind {517                drawLine(518                    color = accent,519                    start = Offset(0f, size.height),520                    end = Offset(size.width, size.height),521                    strokeWidth = 1.dp.toPx(),522                )523            },524        )525        Spacer(Modifier.height(8.dp))526        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {527            Text(528                "✓ save", color = accent, style = PeachType.labelMono,529                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {530                    onSave(draft)531                    editing = false532                }.padding(vertical = 4.dp),533            )534            Text(535                "✗", color = peachColors.T2, style = PeachType.labelMono,536                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {537                    draft = title538                    editing = false539                }.padding(horizontal = 6.dp, vertical = 4.dp),540            )541        }542    }543}544545@Composable546private fun HomographWarning() {547    Row(548        Modifier.fillMaxWidth()549            .padding(top = 8.dp)550            .clip(RoundedCornerShape(8.dp))551            .background(peachColors.BreachBg)552            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))553            .padding(horizontal = 12.dp, vertical = 9.dp),554        verticalAlignment = Alignment.CenterVertically,555    ) {556        Text("!", color = peachColors.Breach, style = PeachType.labelMono)557        Spacer(Modifier.width(10.dp))558        Text(559            "This URL contains non-ASCII or punycode characters.",560            color = peachColors.Breach,561            style = PeachType.labelMono,562            modifier = Modifier.weight(1f),563        )564    }565}566567@Composable568private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {569    val passkey = entry.passkey ?: return570    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {571        Column(Modifier.weight(1f)) {572            Text(573                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,574                color = peachColors.T0,575                style = PeachType.body,576            )577            Text(578                listOfNotNull(579                    passkey.rpId,580                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,581                ).joinToString(" · "),582                color = peachColors.T2,583                style = PeachType.labelMono,584            )585            Text(586                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",587                color = peachColors.T2,588                style = PeachType.labelMono,589                modifier = Modifier.padding(top = 3.dp),590            )591        }592    }593}594595@Composable596private fun CanaryDetailSection(597    entryId: String,598    modifiedAt: Long,599    canary: com.peachpasswords.vault.Canary?,600    isPro: Boolean,601    message: String?,602    testing: Boolean,603    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,604    onTest: (webhookUrl: String) -> Unit,605) {606    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }607    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }608    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }609    val gated = !isPro && canary == null610    FieldShell("canary alert") {611        Column(Modifier.weight(1f)) {612            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {613                Text(614                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",615                    color = peachColors.T0,616                    style = PeachType.body,617                    modifier = Modifier.weight(1f),618                )619                if (gated) {620                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)621                }622                Text(623                    if (enabled) "on" else "off",624                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,625                    style = PeachType.labelMono,626                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {627                        if (gated) {628                            onSave(true, webhookUrl, label)629                        } else {630                            val next = !enabled631                            enabled = next632                            if (!next) onSave(false, webhookUrl, label)633                        }634                    }.padding(horizontal = 6.dp, vertical = 4.dp),635                )636            }637            Text(638                "Fires a silent webhook when this entry's password or username is copied.",639                color = peachColors.T2,640                style = PeachType.labelMono,641                modifier = Modifier.padding(top = 3.dp),642            )643            if (enabled) {644                Spacer(Modifier.height(8.dp))645                PeachField(646                    label = "webhook url",647                    value = webhookUrl,648                    onChange = { webhookUrl = it },649                )650                Text(651                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",652                    color = peachColors.T2,653                    style = PeachType.labelMono,654                )655                PeachField(656                    label = "label",657                    value = label,658                    onChange = { label = it },659                    mono = false,660                )661                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {662                    Text(663                        "save",664                        color = LocalPeachAccent.current,665                        style = PeachType.labelMono,666                        modifier = Modifier.clip(RoundedCornerShape(6.dp))667                            .clickable { onSave(true, webhookUrl, label) }668                            .padding(vertical = 4.dp),669                    )670                    Text(671                        if (testing) "sending..." else "test ping",672                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,673                        style = PeachType.labelMono,674                        modifier = Modifier.clip(RoundedCornerShape(6.dp))675                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }676                            .padding(vertical = 4.dp),677                    )678                }679            }680            message?.let {681                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))682            }683            if (canary?.lastPinged != null || canary?.pingCount != null) {684                Text(685                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),686                    color = peachColors.T2,687                    style = PeachType.labelMono,688                    modifier = Modifier.padding(top = 3.dp),689                )690            }691        }692    }693}694695@Composable696private fun EntryDetailBottomControls(697    currentIndex: Int,698    totalEntries: Int,699    onOpenList: () -> Unit,700    onSearch: () -> Unit,701    onAddEntry: () -> Unit,702    onOpenNotes: () -> Unit,703    onSettings: () -> Unit,704    onLock: () -> Unit,705    onPrevious: () -> Unit,706    onNext: () -> Unit,707    modifier: Modifier = Modifier,708) {709    val accent = LocalPeachAccent.current710    val hasEntries = totalEntries > 0 && currentIndex >= 0711    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"712713    Column(714        modifier715            .fillMaxWidth()716    ) {717        // Gentle fade: scrolling content dissolves into the background718        // instead of hitting a hard opaque edge.719        Box(720            Modifier.fillMaxWidth().height(40.dp)721                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),722        )723        Column(724            Modifier.fillMaxWidth()725                .background(peachColors.Bg)726                .navigationBarsPadding(),727        ) {728            Row(729                Modifier.fillMaxWidth().height(44.dp),730                horizontalArrangement = Arrangement.Center,731                verticalAlignment = Alignment.CenterVertically,732            ) {733                Icon(734                    Icons.AutoMirrored.Rounded.ArrowBack,735                    contentDescription = "Previous entry",736                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,737                    modifier = Modifier738                        .size(36.dp)739                        .clip(RoundedCornerShape(8.dp))740                        .clickable(enabled = hasEntries, onClick = onPrevious)741                        .padding(8.dp),742                )743                Spacer(Modifier.width(PeachSpacing.md))744                Text(counter, color = peachColors.T2, style = PeachType.labelMono)745                Spacer(Modifier.width(PeachSpacing.md))746                Spacer(Modifier.width(4.dp))747                Icon(748                    Icons.AutoMirrored.Rounded.ArrowForward,749                    contentDescription = "Next entry",750                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,751                    modifier = Modifier752                        .size(36.dp)753                        .clip(RoundedCornerShape(8.dp))754                        .clickable(enabled = hasEntries, onClick = onNext)755                        .padding(8.dp),756                )757            }758            PeachDeckBottomBar(759                onSearch = onSearch,760                onAdd = onAddEntry,761                onNotes = onOpenNotes,762                onSettings = onSettings,763                onLock = onLock,764                onSwipeUp = onOpenList,765            )766        }767    }768}769770/** A credential field card: uppercase mono label over its value and local actions. */771@Composable772internal fun FieldShell(773    label: String,774    modifier: Modifier = Modifier,775    stacked: Boolean = false,776    divider: Boolean = false,777    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,778) {779    if (stacked) {780        Column(modifier.fillMaxWidth()) {781            Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {782                Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))783                Spacer(Modifier.height(6.dp))784                Row(verticalAlignment = Alignment.CenterVertically, content = content)785            }786            if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))787        }788        return789    }790    Column(791        modifier.fillMaxWidth()792            .padding(vertical = PeachSpacing.xs)793            .clip(RoundedCornerShape(PeachRadius.StackCard))794            .background(peachColors.SurfaceCard)795            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))796            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),797    ) {798        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))799        Spacer(Modifier.height(6.dp))800        Row(verticalAlignment = Alignment.CenterVertically, content = content)801    }802}803804@Composable805private fun CredentialStack(content: @Composable () -> Unit) {806    Column(807        Modifier.fillMaxWidth()808            .padding(vertical = PeachSpacing.xs)809            .clip(RoundedCornerShape(PeachRadius.StackCard))810            .background(peachColors.SurfaceCard)811            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),812    ) { content() }813}814815/**816 * Text for the ghost PeachScript watermark behind the entry name: the full title,817 * falling back to "P" for a blank title so the hero never renders empty.818 */819internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }820821internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =822    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)823824internal fun shortCredential(credentialId: String): String {825    if (credentialId.length <= 12) return credentialId826    return credentialId.take(6) + "..." + credentialId.takeLast(4)827}828829internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {830    val count = pingCount ?: 0831    val countLabel = "$count total alert" + if (count == 1) "" else "s"832    if (lastPinged == null) return countLabel833    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)834        .format(java.util.Date(lastPinged))835    return "Last triggered: $date · $countLabel"836}