EntryDetailScreen.kt

37.2 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectHorizontalDragGestures15import androidx.compose.foundation.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.layout.wrapContentHeight30import androidx.compose.foundation.rememberScrollState31import androidx.compose.foundation.shape.RoundedCornerShape32import androidx.compose.foundation.text.BasicTextField33import androidx.compose.material.icons.rounded.Check34import androidx.compose.material.icons.rounded.ContentCopy35import androidx.compose.foundation.verticalScroll36import androidx.compose.material.icons.Icons37import androidx.compose.material.icons.automirrored.rounded.ArrowBack38import androidx.compose.material.icons.automirrored.rounded.ArrowForward39import androidx.compose.material3.Icon40import androidx.compose.material3.Text41import androidx.compose.runtime.Composable42import androidx.compose.runtime.getValue43import androidx.compose.runtime.mutableStateOf44import androidx.compose.runtime.produceState45import androidx.compose.runtime.remember46import androidx.compose.runtime.setValue47import androidx.compose.ui.Alignment48import androidx.compose.ui.Modifier49import androidx.compose.ui.draw.clip50import 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                    .verticalScroll(rememberScrollState())147                    .padding(horizontal = PeachSpacing.xl)148                    .padding(bottom = 172.dp),149            ) {150                PeachVaultIdentity("vault") { onNavigate(PeachNav.VaultList) }151                Spacer(Modifier.height(PeachSpacing.sm))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 Garamond 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().height(124.dp)) {194                    Text(195                        text = ghostWatermarkText(e.title),196                        style = PeachType.ghostMark.copy(fontSize = 150.sp),197                        color = peachColors.T0.copy(alpha = 0.03f),198                        maxLines = 1,199                        softWrap = false,200                        overflow = TextOverflow.Clip,201                        modifier = Modifier202                            .align(Alignment.TopCenter)203                            .wrapContentHeight(unbounded = true)204                            .offset(y = (-16).dp),205                    )206                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {207                        EditableTitleHero(208                            title = e.title,209                            onSave = { vm.updateField("title", it) },210                        )211                    }212                }213                Spacer(Modifier.height(PeachSpacing.sm))214215                if (e.isCard()) {216                    CardDetailFields(217                        entry = e,218                        cardRevealed = cardRevealed,219                        cvcRevealed = cvcRevealed,220                        copiedField = copiedField,221                        onToggleCardReveal = { cardRevealed = !cardRevealed },222                        onRevealCvc = {223                            val activity = context as? FragmentActivity224                            if (activity != null && Biometrics.isAvailable(context)) {225                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)226                            } else vm.revealCvc()227                        },228                        onMaskCvc = vm::maskCvc,229                        onCopy = vm::copy,230                        onSaveCard = vm::updateCard,231                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },232                    )233                } else {234                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }235                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }236                    val hasPassword = !e.password.isNullOrBlank()237                    if (!e.isNote()) {238                        CredentialStack {239                            EditableFieldRow(240                                label = "url", field = "url", value = e.url.orEmpty(),241                                copied = copiedField == "url",242                                onCopy = { vm.copy("url", e.url) },243                                onSave = { vm.updateField("url", it) },244                                placeholder = "add url",245                                onEditingChange = { urlEditing = it },246                                stacked = true,247                                divider = showUsernameField || showEmailField || hasPassword,248                            )249                            // Browser distinguishes USERNAME and EMAIL as separate250                            // optional fields, with a "+ add X" link for whichever the251                            // entry doesn't have yet — an entry can carry both.252                            if (showUsernameField) {253                                EditableFieldRow(254                                    label = "username", field = "username", value = e.username.orEmpty(),255                                    copied = copiedField == "username",256                                    onCopy = { vm.copy("username", e.username) },257                                    onSave = { vm.updateField("username", it) },258                                    suggestions = identitySuggestions,259                                    placeholder = "add username",260                                    stacked = true,261                                    divider = showEmailField || hasPassword,262                                )263                            }264                            if (showEmailField) {265                                EditableFieldRow(266                                    label = "email", field = "email", value = e.email.orEmpty(),267                                    copied = copiedField == "email",268                                    onCopy = { vm.copy("email", e.email) },269                                    onSave = { vm.updateField("email", it) },270                                    suggestions = identitySuggestions,271                                    placeholder = "add email",272                                    stacked = true,273                                    divider = hasPassword,274                                )275                            }276                            e.password?.takeIf { it.isNotBlank() }?.let { pw ->277                                EditablePasswordRow(278                                    value = pw,279                                    copied = copiedField == "password",280                                    entryTitle = e.title,281                                    onCopy = { vm.copy("password", pw) },282                                    onSave = { vm.updateField("password", it) },283                                    onGenerate = { generatorOpen = true },284                                    stacked = true,285                                )286                            }287                        }288                    }289                    if (!showUsernameField || !showEmailField) {290                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {291                            if (!showUsernameField) {292                                Text(293                                    "+ add username", color = accent, style = PeachType.labelMono,294                                    modifier = Modifier295                                        .clip(RoundedCornerShape(6.dp))296                                        .clickable { showUsernameField = true }297                                        .padding(vertical = 6.dp),298                                )299                            }300                            if (!showEmailField) {301                                Text(302                                    "+ add email", color = accent, style = PeachType.labelMono,303                                    modifier = Modifier304                                        .clip(RoundedCornerShape(6.dp))305                                        .clickable { showEmailField = true }306                                        .padding(vertical = 6.dp),307                                )308                            }309                        }310                    }311                    AdditionalUrlRows(312                        urls = e.additionalUrls.orEmpty(),313                        copiedField = copiedField,314                        onCopy = { key, value -> vm.copy(key, value) },315                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },316                        onAdd = vm::addAdditionalUrl,317                        showAdd = urlEditing,318                    )319                    if (e.hasPasskey()) {320                        PasskeyDetailSection(e)321                    }322                    // Always show the history affordance (browser parity) — visible323                    // even before any password change, expanding to an empty-state324                    // explainer so the feature is discoverable rather than hidden325                    // until history already exists.326                    val history = e.passwordHistory.orEmpty()327                    PasswordHistoryToggle(328                        count = history.size,329                        expanded = historyExpanded,330                        onToggle = { historyExpanded = !historyExpanded },331                    )332                    AnimatedVisibility(visible = historyExpanded) {333                        Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {334                            PasswordHistoryList(335                                history = history,336                                onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },337                                onCopy = { oldPw -> vm.copy("password", oldPw) },338                            )339                        }340                    }341                    if (e.hasTOTP()) {342                        Spacer(Modifier.height(4.dp))343                        TotpBandEditable(344                            totpField = e.totp!!,345                            copied = copiedField == "totp",346                            onCopy = vm::copyTotp,347                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },348                        )349                    }350                }351                e.notes?.takeIf { it.isNotBlank() }?.let { v ->352                    // A note authored in the browser is HTML: shown rendered,353                    // edited as markup, saved back as the same tags.354                    val notesAreHtml = isNoteHtml(v)355                    EditableFieldRow(356                        label = "notes", field = "notes", value = v,357                        copied = copiedField == "notes",358                        onCopy = { vm.copy("notes", notePlainText(v)) },359                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },360                        mono = false, singleLine = false,361                        displayText = noteRendered(v),362                        draftText = noteEditorText(v),363                    )364                }365                EntryDetailActions(366                    confirmingDelete = confirmingDelete,367                    onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },368                    onDelete = {369                        if (confirmingDelete) {370                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })371                        } else {372                            confirmingDelete = true373                        }374                    },375                )376                Spacer(Modifier.height(PeachSpacing.xxl + PeachSpacing.xxl))377                DetailTagRow(378                    tags = e.tags.orEmpty(),379                    allTags = allTags,380                    onAddTag = vm::addTag,381                    onRemoveTag = vm::removeTag,382                )383                if (!e.isNote() && !e.isCard()) {384                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),385                    // unless a canary is already configured for this entry — every386                    // entry showing an "Off" canary block permanently was clutter387                    // most people never touch.388                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }389                    if (advancedExpanded) {390                        CanaryDetailSection(391                            entryId = e.id,392                            modifiedAt = e.modifiedAt,393                            canary = e.canary,394                            isPro = isPro,395                            message = canaryMessage,396                            testing = canaryTesting,397                            onSave = vm::saveCanary,398                            onTest = vm::testCanary,399                        )400                    } else {401                        FieldShell("advanced") {402                            Text(403                                "Canary alert and protection",404                                color = peachColors.T2,405                                style = PeachType.bodyMono,406                                modifier = Modifier.weight(1f),407                            )408                            Text(409                                "open", color = LocalPeachAccent.current, style = PeachType.labelMono,410                                modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),411                            )412                        }413                    }414                }415416                Spacer(Modifier.height(PeachSpacing.xxl))417            }418419            if (e != null) {420                EntryDetailBottomControls(421                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },422                    totalEntries = activeEntries.size,423                    onOpenList = { onNavigate(PeachNav.VaultList) },424                    onSearch = { onNavigate(PeachNav.VaultSearch) },425                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },426                    onOpenNotes = { onNavigate(PeachNav.Notes) },427                    onSettings = { onNavigate(PeachNav.Settings) },428                    onLock = vm::lock,429                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },430                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },431                    modifier = Modifier.align(Alignment.BottomCenter),432                )433            }434435            if (generatorOpen) {436                GeneratorSheet(437                    copied = copiedField == "genDraft",438                    onCopy = { draft -> vm.copy("genDraft", draft) },439                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },440                    onDismiss = { generatorOpen = false },441                )442            }443        }444    }445}446447@Composable448private fun EntryDetailActions(449    confirmingDelete: Boolean,450    onEdit: () -> Unit,451    onDelete: () -> Unit,452) {453    val accent = LocalPeachAccent.current454    Row(455        Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),456        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),457    ) {458        DetailActionButton(459            label = "edit",460            color = peachColors.Bg,461            background = accent,462            border = accent,463            onClick = onEdit,464            modifier = Modifier.weight(1f),465        )466        DetailActionButton(467            label = if (confirmingDelete) "confirm trash" else "move to trash",468            color = if (confirmingDelete) peachColors.Breach else accent,469            background = Color.Transparent,470            border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,471            onClick = onDelete,472            modifier = Modifier.weight(1f),473        )474    }475}476477@Composable478private fun DetailActionButton(479    label: String,480    color: Color,481    background: Color,482    border: Color,483    onClick: () -> Unit,484    modifier: Modifier = Modifier,485) {486    Box(487        modifier.height(52.dp)488            .clip(RoundedCornerShape(PeachRadius.Button))489            .background(background)490            .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))491            .clickable(onClick = onClick),492        contentAlignment = Alignment.Center,493    ) {494        Text(label, color = color, style = PeachType.bodyMono)495    }496}497498@Composable499private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {500    val accent = LocalPeachAccent.current501    var editing by remember(title) { mutableStateOf(false) }502    var draft by remember(title) { mutableStateOf(title) }503    if (!editing) {504        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {505            CharEnterText(506                text = title,507                style = PeachType.entryNameHero.copy(color = peachColors.T0),508            )509        }510        return511    }512    Column(Modifier.fillMaxWidth()) {513        BasicTextField(514            value = draft,515            onValueChange = { draft = it },516            singleLine = true,517            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),518            cursorBrush = SolidColor(accent),519            modifier = Modifier.fillMaxWidth().drawBehind {520                drawLine(521                    color = accent,522                    start = Offset(0f, size.height),523                    end = Offset(size.width, size.height),524                    strokeWidth = 1.dp.toPx(),525                )526            },527        )528        Spacer(Modifier.height(8.dp))529        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {530            Text(531                "✓ save", color = accent, style = PeachType.labelMono,532                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {533                    onSave(draft)534                    editing = false535                }.padding(vertical = 4.dp),536            )537            Text(538                "✗", color = peachColors.T2, style = PeachType.labelMono,539                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {540                    draft = title541                    editing = false542                }.padding(horizontal = 6.dp, vertical = 4.dp),543            )544        }545    }546}547548@Composable549private fun HomographWarning() {550    Row(551        Modifier.fillMaxWidth()552            .padding(top = 8.dp)553            .clip(RoundedCornerShape(8.dp))554            .background(peachColors.BreachBg)555            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))556            .padding(horizontal = 12.dp, vertical = 9.dp),557        verticalAlignment = Alignment.CenterVertically,558    ) {559        Text("!", color = peachColors.Breach, style = PeachType.labelMono)560        Spacer(Modifier.width(10.dp))561        Text(562            "This URL contains non-ASCII or punycode characters.",563            color = peachColors.Breach,564            style = PeachType.labelMono,565            modifier = Modifier.weight(1f),566        )567    }568}569570@Composable571private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {572    val passkey = entry.passkey ?: return573    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {574        Column(Modifier.weight(1f)) {575            Text(576                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,577                color = peachColors.T0,578                style = PeachType.body,579            )580            Text(581                listOfNotNull(582                    passkey.rpId,583                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,584                ).joinToString(" · "),585                color = peachColors.T2,586                style = PeachType.labelMono,587            )588            Text(589                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",590                color = peachColors.T2,591                style = PeachType.labelMono,592                modifier = Modifier.padding(top = 3.dp),593            )594        }595    }596}597598@Composable599private fun CanaryDetailSection(600    entryId: String,601    modifiedAt: Long,602    canary: com.peachpasswords.vault.Canary?,603    isPro: Boolean,604    message: String?,605    testing: Boolean,606    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,607    onTest: (webhookUrl: String) -> Unit,608) {609    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }610    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }611    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }612    val gated = !isPro && canary == null613    FieldShell("canary alert") {614        Column(Modifier.weight(1f)) {615            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {616                Text(617                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",618                    color = peachColors.T0,619                    style = PeachType.body,620                    modifier = Modifier.weight(1f),621                )622                if (gated) {623                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)624                }625                Text(626                    if (enabled) "on" else "off",627                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,628                    style = PeachType.labelMono,629                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {630                        if (gated) {631                            onSave(true, webhookUrl, label)632                        } else {633                            val next = !enabled634                            enabled = next635                            if (!next) onSave(false, webhookUrl, label)636                        }637                    }.padding(horizontal = 6.dp, vertical = 4.dp),638                )639            }640            Text(641                "Fires a silent webhook when this entry's password or username is copied.",642                color = peachColors.T2,643                style = PeachType.labelMono,644                modifier = Modifier.padding(top = 3.dp),645            )646            if (enabled) {647                Spacer(Modifier.height(8.dp))648                PeachField(649                    label = "webhook url",650                    value = webhookUrl,651                    onChange = { webhookUrl = it },652                )653                Text(654                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",655                    color = peachColors.T2,656                    style = PeachType.labelMono,657                )658                PeachField(659                    label = "label",660                    value = label,661                    onChange = { label = it },662                    mono = false,663                )664                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {665                    Text(666                        "save",667                        color = LocalPeachAccent.current,668                        style = PeachType.labelMono,669                        modifier = Modifier.clip(RoundedCornerShape(6.dp))670                            .clickable { onSave(true, webhookUrl, label) }671                            .padding(vertical = 4.dp),672                    )673                    Text(674                        if (testing) "sending..." else "test ping",675                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,676                        style = PeachType.labelMono,677                        modifier = Modifier.clip(RoundedCornerShape(6.dp))678                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }679                            .padding(vertical = 4.dp),680                    )681                }682            }683            message?.let {684                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))685            }686            if (canary?.lastPinged != null || canary?.pingCount != null) {687                Text(688                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),689                    color = peachColors.T2,690                    style = PeachType.labelMono,691                    modifier = Modifier.padding(top = 3.dp),692                )693            }694        }695    }696}697698@Composable699private fun EntryDetailBottomControls(700    currentIndex: Int,701    totalEntries: Int,702    onOpenList: () -> Unit,703    onSearch: () -> Unit,704    onAddEntry: () -> Unit,705    onOpenNotes: () -> Unit,706    onSettings: () -> Unit,707    onLock: () -> Unit,708    onPrevious: () -> Unit,709    onNext: () -> Unit,710    modifier: Modifier = Modifier,711) {712    val accent = LocalPeachAccent.current713    val hasEntries = totalEntries > 0 && currentIndex >= 0714    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"715716    Column(717        modifier718            .fillMaxWidth()719    ) {720        // Gentle fade: scrolling content dissolves into the background721        // instead of hitting a hard opaque edge.722        Box(723            Modifier.fillMaxWidth().height(40.dp)724                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),725        )726        Column(727            Modifier.fillMaxWidth()728                .navigationBarsPadding(),729        ) {730            Row(731                Modifier.fillMaxWidth().height(44.dp),732                horizontalArrangement = Arrangement.Center,733                verticalAlignment = Alignment.CenterVertically,734            ) {735                Icon(736                    Icons.AutoMirrored.Rounded.ArrowBack,737                    contentDescription = "Previous entry",738                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,739                    modifier = Modifier740                        .size(36.dp)741                        .clip(RoundedCornerShape(8.dp))742                        .clickable(enabled = hasEntries, onClick = onPrevious)743                        .padding(8.dp),744                )745                Spacer(Modifier.width(PeachSpacing.md))746                Text(counter, color = peachColors.T2, style = PeachType.labelMono)747                Spacer(Modifier.width(PeachSpacing.md))748                Spacer(Modifier.width(4.dp))749                Icon(750                    Icons.AutoMirrored.Rounded.ArrowForward,751                    contentDescription = "Next entry",752                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,753                    modifier = Modifier754                        .size(36.dp)755                        .clip(RoundedCornerShape(8.dp))756                        .clickable(enabled = hasEntries, onClick = onNext)757                        .padding(8.dp),758                )759            }760            PeachDeckBottomBar(761                onSearch = onSearch,762                onAdd = onAddEntry,763                onNotes = onOpenNotes,764                onSettings = onSettings,765                onLock = onLock,766                onSwipeUp = onOpenList,767            )768        }769    }770}771772/** A credential field card: uppercase mono label over its value and local actions. */773@Composable774internal fun FieldShell(775    label: String,776    modifier: Modifier = Modifier,777    stacked: Boolean = false,778    divider: Boolean = false,779    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,780) {781    if (stacked) {782        Column(modifier.fillMaxWidth()) {783            Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {784                Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))785                Spacer(Modifier.height(6.dp))786                Row(verticalAlignment = Alignment.CenterVertically, content = content)787            }788            if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))789        }790        return791    }792    Column(793        modifier.fillMaxWidth()794            .padding(vertical = PeachSpacing.xs)795            .clip(RoundedCornerShape(PeachRadius.StackCard))796            .background(peachColors.SurfaceCard)797            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))798            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),799    ) {800        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))801        Spacer(Modifier.height(6.dp))802        Row(verticalAlignment = Alignment.CenterVertically, content = content)803    }804}805806@Composable807private fun CredentialStack(content: @Composable () -> Unit) {808    Column(809        Modifier.fillMaxWidth()810            .padding(vertical = PeachSpacing.xs)811            .clip(RoundedCornerShape(PeachRadius.StackCard))812            .background(peachColors.SurfaceCard)813            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),814    ) { content() }815}816817/**818 * Text for the ghost Garamond watermark behind the entry name: the full title,819 * falling back to "P" for a blank title so the hero never renders empty.820 */821internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }822823internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =824    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)825826internal fun shortCredential(credentialId: String): String {827    if (credentialId.length <= 12) return credentialId828    return credentialId.take(6) + "..." + credentialId.takeLast(4)829}830831internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {832    val count = pingCount ?: 0833    val countLabel = "$count total alert" + if (count == 1) "" else "s"834    if (lastPinged == null) return countLabel835    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)836        .format(java.util.Date(lastPinged))837    return "Last triggered: $date · $countLabel"838}