EntryDetailScreen.kt

36.8 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectHorizontalDragGestures15import androidx.compose.foundation.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.Add33import androidx.compose.material.icons.rounded.Check34import androidx.compose.material.icons.rounded.ContentCopy35import androidx.compose.material.icons.rounded.Search36import androidx.compose.material.icons.rounded.Settings37import androidx.compose.foundation.verticalScroll38import androidx.compose.material.icons.Icons39import androidx.compose.material.icons.automirrored.rounded.ArrowBack40import androidx.compose.material.icons.automirrored.rounded.ArrowForward41import androidx.compose.material.icons.outlined.Description42import androidx.compose.material.icons.outlined.Settings43import androidx.compose.material.icons.outlined.Lock44import androidx.compose.material3.Icon45import androidx.compose.material3.Text46import androidx.compose.runtime.Composable47import androidx.compose.runtime.getValue48import androidx.compose.runtime.mutableStateOf49import androidx.compose.runtime.produceState50import androidx.compose.runtime.remember51import androidx.compose.runtime.setValue52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.clipToBounds56import androidx.compose.ui.draw.drawBehind57import androidx.compose.ui.geometry.Offset58import androidx.compose.ui.geometry.Size59import androidx.compose.ui.graphics.Brush60import androidx.compose.ui.graphics.Color61import androidx.compose.ui.graphics.SolidColor62import androidx.compose.ui.graphics.graphicsLayer63import androidx.compose.ui.platform.LocalContext64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.text.TextStyle66import androidx.compose.ui.text.font.FontStyle67import androidx.compose.ui.text.style.TextOverflow68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import androidx.compose.ui.unit.sp71import androidx.compose.runtime.collectAsState72import androidx.fragment.app.FragmentActivity73import androidx.hilt.navigation.compose.hiltViewModel74import com.peachpasswords.shared.detectHomograph75import com.peachpasswords.ui.PeachNav76import com.peachpasswords.ui.components.CharEnterText77import com.peachpasswords.ui.components.CopyButton78import com.peachpasswords.ui.components.PeachEmptyOrEndState79import com.peachpasswords.ui.components.PeachWordmark80import com.peachpasswords.ui.components.SecureFlagEffect81import com.peachpasswords.ui.components.TOTPRing82import com.peachpasswords.ui.lock.Biometrics83import com.peachpasswords.ui.theme.LocalPeachAccent84import com.peachpasswords.ui.theme.LocalReduceMotion85import com.peachpasswords.ui.theme.PeachSpacing86import com.peachpasswords.ui.theme.PeachRadius87import com.peachpasswords.ui.theme.peachColors88import com.peachpasswords.ui.theme.PeachType89import com.peachpasswords.ui.theme.ShellBackground90import com.peachpasswords.vault.TOTPGenerator91import com.peachpasswords.vault.hasTOTP92import com.peachpasswords.vault.hasPasskey93import com.peachpasswords.vault.isBreach94import com.peachpasswords.vault.isCard95import com.peachpasswords.vault.isNote96import com.peachpasswords.vault.isPasskeyOnly9798@Composable99fun EntryDetailScreen(100    id: String,101    onNavigate: (PeachNav) -> Unit,102    // Null when this entry is the post-unlock landing surface (C1: entry deck103    // is primary, not a child of the vault list) — there's genuinely nothing104    // to go "back" to, so the top chrome shows a lock affordance instead.105    onBack: (() -> Unit)?,106    modifier: Modifier = Modifier,107    vm: EntryDetailViewModel = hiltViewModel(),108) {109    SecureFlagEffect()110111    val entry by vm.entry.collectAsState()112    val activeEntries by vm.activeEntries.collectAsState()113    val identitySuggestions by vm.identitySuggestions.collectAsState()114    val allTags by vm.allTags.collectAsState()115    val isPro by vm.isPro.collectAsState()116    val cvcRevealed by vm.cvcRevealed.collectAsState()117    var cardRevealed by remember { mutableStateOf(false) }118    val copiedField by vm.copiedField.collectAsState()119    val canaryMessage by vm.canaryMessage.collectAsState()120    val canaryTesting by vm.canaryTesting.collectAsState()121    val context = LocalContext.current122    val accent = LocalPeachAccent.current123    var confirmingDelete by remember { mutableStateOf(false) }124    var generatorOpen by remember { mutableStateOf(false) }125    var historyExpanded by remember { mutableStateOf(false) }126127    val e = entry128    ShellBackground(modifier) {129        Box(130            Modifier.fillMaxSize().statusBarsPadding()131                // C1: swipe left/right between deck entries, mirroring the132                // prev/next arrows in EntryDetailSearchStrip. Resolved on133                // drag end (not per-event) so an in-progress vertical scroll134                // gesture on the content below can't accidentally trigger it.135                .pointerInput(vm) {136                    var dragAccum = 0f137                    detectHorizontalDragGestures(138                        onDragStart = { dragAccum = 0f },139                        onDragEnd = {140                            when {141                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)142                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)143                            }144                            dragAccum = 0f145                        },146                        onDragCancel = { dragAccum = 0f },147                    ) { _, dragAmount -> dragAccum += dragAmount }148                },149        ) {150            Column(151                Modifier.fillMaxSize()152                    .padding(bottom = 172.dp)153                    .verticalScroll(rememberScrollState())154                    .padding(horizontal = PeachSpacing.xl),155            ) {156                EntryDetailIdentity()157                Spacer(Modifier.height(PeachSpacing.xxl))158159                if (e == null) {160                    PeachEmptyOrEndState(161                        message = "entry not found",162                        detail = "It may have been moved or deleted.",163                        modifier = Modifier.padding(top = PeachSpacing.xxl),164                    )165                    return@Column166                }167168                // D11 — breach banner strip when this entry's password is known-compromised.169                if (e.isBreach()) {170                    Row(171                        Modifier.fillMaxWidth()172                            .background(peachColors.BreachBg)173                            .padding(horizontal = 16.dp, vertical = 8.dp),174                        verticalAlignment = Alignment.CenterVertically,175                    ) {176                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)177                        Spacer(Modifier.width(10.dp))178                        Text(179                            "Password found in a data breach — change it now",180                            color = peachColors.Breach, style = PeachType.labelMono,181                            modifier = Modifier.weight(1f),182                        )183                        Text(184                            "Change password",185                            color = accent, style = PeachType.labelMono,186                            modifier = Modifier.clip(RoundedCornerShape(6.dp))187                                .clickable { generatorOpen = true }188                                .padding(horizontal = 6.dp, vertical = 4.dp),189                        )190                    }191                }192                if (entryHasHomographWarning(e)) {193                    HomographWarning()194                }195196                // Hero — ghost PeachScript watermark behind the entry name. The full197                // title is drawn (not just its first letter); a long title overflows198                // and clips naturally, which is the intended watermark behavior.199                Box(Modifier.fillMaxWidth().clipToBounds()) {200                    Text(201                        text = ghostWatermarkText(e.title),202                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),203                        color = peachColors.T0.copy(alpha = 0.03f),204                        maxLines = 1,205                        softWrap = false,206                        overflow = TextOverflow.Clip,207                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),208                    )209                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {210                        EditableTitleHero(211                            title = e.title,212                            onSave = { vm.updateField("title", it) },213                        )214                    }215                }216                Spacer(Modifier.height(PeachSpacing.lg))217218                if (e.isCard()) {219                    CardDetailFields(220                        entry = e,221                        cardRevealed = cardRevealed,222                        cvcRevealed = cvcRevealed,223                        copiedField = copiedField,224                        onToggleCardReveal = { cardRevealed = !cardRevealed },225                        onRevealCvc = {226                            val activity = context as? FragmentActivity227                            if (activity != null && Biometrics.isAvailable(context)) {228                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)229                            } else vm.revealCvc()230                        },231                        onMaskCvc = vm::maskCvc,232                        onCopy = vm::copy,233                        onSaveCard = vm::updateCard,234                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },235                    )236                } else {237                    if (!e.isNote()) {238                        EditableFieldRow(239                            label = "url", field = "url", value = e.url.orEmpty(),240                            copied = copiedField == "url",241                            onCopy = { vm.copy("url", e.url) },242                            onSave = { vm.updateField("url", it) },243                            placeholder = "add url",244                        )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. This249                    // used to just omit the row entirely for a blank field,250                    // with no way to add it from the detail screen.251                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }252                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }253                    if (showUsernameField) {254                        EditableFieldRow(255                            label = "username", field = "username", value = e.username.orEmpty(),256                            copied = copiedField == "username",257                            onCopy = { vm.copy("username", e.username) },258                            onSave = { vm.updateField("username", it) },259                            suggestions = identitySuggestions,260                            placeholder = "add username",261                        )262                    }263                    if (showEmailField) {264                        EditableFieldRow(265                            label = "email", field = "email", value = e.email.orEmpty(),266                            copied = copiedField == "email",267                            onCopy = { vm.copy("email", e.email) },268                            onSave = { vm.updateField("email", it) },269                            suggestions = identitySuggestions,270                            placeholder = "add email",271                        )272                    }273                    if (!showUsernameField || !showEmailField) {274                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {275                            if (!showUsernameField) {276                                Text(277                                    "+ add username", color = accent, style = PeachType.labelMono,278                                    modifier = Modifier279                                        .clip(RoundedCornerShape(6.dp))280                                        .clickable { showUsernameField = true }281                                        .padding(vertical = 6.dp),282                                )283                            }284                            if (!showEmailField) {285                                Text(286                                    "+ add email", color = accent, style = PeachType.labelMono,287                                    modifier = Modifier288                                        .clip(RoundedCornerShape(6.dp))289                                        .clickable { showEmailField = true }290                                        .padding(vertical = 6.dp),291                                )292                            }293                        }294                    }295                    AdditionalUrlRows(296                        urls = e.additionalUrls.orEmpty(),297                        copiedField = copiedField,298                        onCopy = { key, value -> vm.copy(key, value) },299                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },300                        onAdd = vm::addAdditionalUrl,301                    )302                    if (e.hasPasskey()) {303                        PasskeyDetailSection(e)304                    }305                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->306                        EditablePasswordRow(307                            value = pw,308                            copied = copiedField == "password",309                            entryTitle = e.title,310                            onCopy = { vm.copy("password", pw) },311                            onSave = { vm.updateField("password", it) },312                            onGenerate = { generatorOpen = true },313                        )314                    }315                    // Always show the history affordance (browser parity) — visible316                    // even before any password change, expanding to an empty-state317                    // explainer so the feature is discoverable rather than hidden318                    // until history already exists.319                    val history = e.passwordHistory.orEmpty()320                    PasswordHistoryToggle(321                        count = history.size,322                        expanded = historyExpanded,323                        onToggle = { historyExpanded = !historyExpanded },324                    )325                    AnimatedVisibility(visible = historyExpanded) {326                        Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {327                            PasswordHistoryList(328                                history = history,329                                onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },330                                onCopy = { oldPw -> vm.copy("password", oldPw) },331                            )332                        }333                    }334                    if (e.hasTOTP()) {335                        Spacer(Modifier.height(4.dp))336                        TotpBandEditable(337                            totpField = e.totp!!,338                            copied = copiedField == "totp",339                            onCopy = vm::copyTotp,340                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },341                        )342                    }343                }344                e.notes?.takeIf { it.isNotBlank() }?.let { v ->345                    // A note authored in the browser is HTML: shown rendered,346                    // edited as markup, saved back as the same tags.347                    val notesAreHtml = isNoteHtml(v)348                    EditableFieldRow(349                        label = "notes", field = "notes", value = v,350                        copied = copiedField == "notes",351                        onCopy = { vm.copy("notes", notePlainText(v)) },352                        onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },353                        mono = false, singleLine = false,354                        displayText = noteRendered(v),355                        draftText = noteEditorText(v),356                    )357                }358                EntryDetailActions(359                    confirmingDelete = confirmingDelete,360                    onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },361                    onDelete = {362                        if (confirmingDelete) {363                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })364                        } else {365                            confirmingDelete = true366                        }367                    },368                )369                Spacer(Modifier.height(PeachSpacing.md))370                DetailTagRow(371                    tags = e.tags.orEmpty(),372                    allTags = allTags,373                    onAddTag = vm::addTag,374                    onRemoveTag = vm::removeTag,375                )376                if (!e.isNote() && !e.isCard()) {377                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),378                    // unless a canary is already configured for this entry — every379                    // entry showing an "Off" canary block permanently was clutter380                    // most people never touch.381                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }382                    if (advancedExpanded) {383                        CanaryDetailSection(384                            entryId = e.id,385                            modifiedAt = e.modifiedAt,386                            canary = e.canary,387                            isPro = isPro,388                            message = canaryMessage,389                            testing = canaryTesting,390                            onSave = vm::saveCanary,391                            onTest = vm::testCanary,392                        )393                    } else {394                        Text(395                            "+ advanced",396                            color = LocalPeachAccent.current,397                            style = PeachType.labelMono,398                            modifier = Modifier399                                .clip(RoundedCornerShape(6.dp))400                                .clickable { advancedExpanded = true }401                                .padding(vertical = 6.dp),402                        )403                    }404                }405406                Spacer(Modifier.height(PeachSpacing.xxl))407            }408409            if (e != null) {410                EntryDetailBottomControls(411                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },412                    totalEntries = activeEntries.size,413                    onOpenList = { onNavigate(PeachNav.VaultList) },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 EntryDetailIdentity(438) {439    Row(440        Modifier.fillMaxWidth().height(64.dp),441        verticalAlignment = Alignment.CenterVertically,442    ) {443        PeachWordmark()444        Spacer(Modifier.width(PeachSpacing.md))445        Text("VAULT", color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))446    }447}448449@Composable450private fun EntryDetailActions(451    confirmingDelete: Boolean,452    onEdit: () -> Unit,453    onDelete: () -> Unit,454) {455    val accent = LocalPeachAccent.current456    Row(457        Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),458        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),459    ) {460        DetailActionButton(461            label = "edit",462            color = peachColors.Bg,463            background = accent,464            border = accent,465            onClick = onEdit,466            modifier = Modifier.weight(1f),467        )468        DetailActionButton(469            label = if (confirmingDelete) "confirm trash" else "move to trash",470            color = if (confirmingDelete) peachColors.Breach else accent,471            background = Color.Transparent,472            border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,473            onClick = onDelete,474            modifier = Modifier.weight(1f),475        )476    }477}478479@Composable480private fun DetailActionButton(481    label: String,482    color: Color,483    background: Color,484    border: Color,485    onClick: () -> Unit,486    modifier: Modifier = Modifier,487) {488    Box(489        modifier.height(52.dp)490            .clip(RoundedCornerShape(PeachRadius.Button))491            .background(background)492            .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))493            .clickable(onClick = onClick),494        contentAlignment = Alignment.Center,495    ) {496        Text(label, color = color, style = PeachType.bodyMono)497    }498}499500@Composable501private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {502    val accent = LocalPeachAccent.current503    var editing by remember(title) { mutableStateOf(false) }504    var draft by remember(title) { mutableStateOf(title) }505    if (!editing) {506        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {507            CharEnterText(508                text = title,509                style = PeachType.entryNameHero.copy(color = peachColors.T0),510            )511        }512        return513    }514    Column(Modifier.fillMaxWidth()) {515        BasicTextField(516            value = draft,517            onValueChange = { draft = it },518            singleLine = true,519            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),520            cursorBrush = SolidColor(accent),521            modifier = Modifier.fillMaxWidth().drawBehind {522                drawLine(523                    color = accent,524                    start = Offset(0f, size.height),525                    end = Offset(size.width, size.height),526                    strokeWidth = 1.dp.toPx(),527                )528            },529        )530        Spacer(Modifier.height(8.dp))531        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {532            Text(533                "✓ save", color = accent, style = PeachType.labelMono,534                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {535                    onSave(draft)536                    editing = false537                }.padding(vertical = 4.dp),538            )539            Text(540                "✗", color = peachColors.T2, style = PeachType.labelMono,541                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {542                    draft = title543                    editing = false544                }.padding(horizontal = 6.dp, vertical = 4.dp),545            )546        }547    }548}549550@Composable551private fun HomographWarning() {552    Row(553        Modifier.fillMaxWidth()554            .padding(top = 8.dp)555            .clip(RoundedCornerShape(8.dp))556            .background(peachColors.BreachBg)557            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))558            .padding(horizontal = 12.dp, vertical = 9.dp),559        verticalAlignment = Alignment.CenterVertically,560    ) {561        Text("!", color = peachColors.Breach, style = PeachType.labelMono)562        Spacer(Modifier.width(10.dp))563        Text(564            "This URL contains non-ASCII or punycode characters.",565            color = peachColors.Breach,566            style = PeachType.labelMono,567            modifier = Modifier.weight(1f),568        )569    }570}571572@Composable573private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {574    val passkey = entry.passkey ?: return575    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {576        Column(Modifier.weight(1f)) {577            Text(578                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,579                color = peachColors.T0,580                style = PeachType.body,581            )582            Text(583                listOfNotNull(584                    passkey.rpId,585                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,586                ).joinToString(" · "),587                color = peachColors.T2,588                style = PeachType.labelMono,589            )590            Text(591                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",592                color = peachColors.T2,593                style = PeachType.labelMono,594                modifier = Modifier.padding(top = 3.dp),595            )596        }597    }598}599600@Composable601private fun CanaryDetailSection(602    entryId: String,603    modifiedAt: Long,604    canary: com.peachpasswords.vault.Canary?,605    isPro: Boolean,606    message: String?,607    testing: Boolean,608    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,609    onTest: (webhookUrl: String) -> Unit,610) {611    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }612    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }613    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }614    val gated = !isPro && canary == null615    FieldShell("canary alert") {616        Column(Modifier.weight(1f)) {617            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {618                Text(619                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",620                    color = peachColors.T0,621                    style = PeachType.body,622                    modifier = Modifier.weight(1f),623                )624                if (gated) {625                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)626                }627                Text(628                    if (enabled) "on" else "off",629                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,630                    style = PeachType.labelMono,631                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {632                        if (gated) {633                            onSave(true, webhookUrl, label)634                        } else {635                            val next = !enabled636                            enabled = next637                            if (!next) onSave(false, webhookUrl, label)638                        }639                    }.padding(horizontal = 6.dp, vertical = 4.dp),640                )641            }642            Text(643                "Fires a silent webhook when this entry's password or username is copied.",644                color = peachColors.T2,645                style = PeachType.labelMono,646                modifier = Modifier.padding(top = 3.dp),647            )648            if (enabled) {649                Spacer(Modifier.height(8.dp))650                PeachField(651                    label = "webhook url",652                    value = webhookUrl,653                    onChange = { webhookUrl = it },654                )655                Text(656                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",657                    color = peachColors.T2,658                    style = PeachType.labelMono,659                )660                PeachField(661                    label = "label",662                    value = label,663                    onChange = { label = it },664                    mono = false,665                )666                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {667                    Text(668                        "save",669                        color = LocalPeachAccent.current,670                        style = PeachType.labelMono,671                        modifier = Modifier.clip(RoundedCornerShape(6.dp))672                            .clickable { onSave(true, webhookUrl, label) }673                            .padding(vertical = 4.dp),674                    )675                    Text(676                        if (testing) "sending..." else "test ping",677                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,678                        style = PeachType.labelMono,679                        modifier = Modifier.clip(RoundedCornerShape(6.dp))680                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }681                            .padding(vertical = 4.dp),682                    )683                }684            }685            message?.let {686                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))687            }688            if (canary?.lastPinged != null || canary?.pingCount != null) {689                Text(690                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),691                    color = peachColors.T2,692                    style = PeachType.labelMono,693                    modifier = Modifier.padding(top = 3.dp),694                )695            }696        }697    }698}699700@Composable701private fun EntryDetailBottomControls(702    currentIndex: Int,703    totalEntries: Int,704    onOpenList: () -> Unit,705    onAddEntry: () -> Unit,706    onOpenNotes: () -> Unit,707    onSettings: () -> Unit,708    onLock: () -> Unit,709    onPrevious: () -> Unit,710    onNext: () -> Unit,711    modifier: Modifier = Modifier,712) {713    val accent = LocalPeachAccent.current714    val hasEntries = totalEntries > 0 && currentIndex >= 0715    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"716717    Column(718        modifier719            .fillMaxWidth()720    ) {721        // Gentle fade: scrolling content dissolves into the background722        // instead of hitting a hard opaque edge.723        Box(724            Modifier.fillMaxWidth().height(40.dp)725                .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),726        )727        Column(728            Modifier.fillMaxWidth()729                .background(peachColors.Bg)730                .navigationBarsPadding(),731        ) {732            Row(733                Modifier.fillMaxWidth().height(44.dp),734                horizontalArrangement = Arrangement.Center,735                verticalAlignment = Alignment.CenterVertically,736            ) {737                Icon(738                    Icons.AutoMirrored.Rounded.ArrowBack,739                    contentDescription = "Previous entry",740                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,741                    modifier = Modifier742                        .size(36.dp)743                        .clip(RoundedCornerShape(8.dp))744                        .clickable(enabled = hasEntries, onClick = onPrevious)745                        .padding(8.dp),746                )747                Spacer(Modifier.width(PeachSpacing.md))748                Text(counter, color = peachColors.T2, style = PeachType.labelMono)749                Spacer(Modifier.width(PeachSpacing.md))750                Spacer(Modifier.width(4.dp))751                Icon(752                    Icons.AutoMirrored.Rounded.ArrowForward,753                    contentDescription = "Next entry",754                    tint = if (hasEntries) peachColors.T0 else peachColors.T2,755                    modifier = Modifier756                        .size(36.dp)757                        .clip(RoundedCornerShape(8.dp))758                        .clickable(enabled = hasEntries, onClick = onNext)759                        .padding(8.dp),760                )761            }762            Row(763                Modifier.fillMaxWidth()764                    .padding(horizontal = PeachSpacing.xl, vertical = PeachSpacing.sm)765                    .height(64.dp)766                    .clip(RoundedCornerShape(PeachRadius.Pill))767                    .background(peachColors.S1)768                    .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.Pill))769                    .padding(horizontal = PeachSpacing.md),770                horizontalArrangement = Arrangement.SpaceBetween,771                verticalAlignment = Alignment.CenterVertically,772            ) {773                BottomBarAction(Icons.Rounded.Search, "search vault", accent, onOpenList)774                BottomBarAction(Icons.Rounded.Add, "new entry", peachColors.T0, onAddEntry)775                BottomBarAction(Icons.Outlined.Description, "Notes", peachColors.T0, onOpenNotes)776                BottomBarAction(Icons.Outlined.Settings, "settings", peachColors.T0, onSettings)777                BottomBarAction(Icons.Outlined.Lock, "Lock vault", accent, onLock)778            }779        }780    }781}782783@Composable784private fun BottomBarAction(785    icon: androidx.compose.ui.graphics.vector.ImageVector,786    label: String,787    tint: Color,788    onClick: () -> Unit,789) {790    Box(791        Modifier.size(48.dp)792            .clip(RoundedCornerShape(PeachRadius.IconButton))793            .clickable(onClick = onClick),794        contentAlignment = Alignment.Center,795    ) {796        Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(22.dp))797    }798}799800/** A credential field card: uppercase mono label over its value and local actions. */801@Composable802internal fun FieldShell(803    label: String,804    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,805) {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            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),813    ) {814        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))815        Spacer(Modifier.height(6.dp))816        Row(verticalAlignment = Alignment.CenterVertically, content = content)817    }818}819820/**821 * Text for the ghost PeachScript watermark behind the entry name: the full title,822 * falling back to "P" for a blank title so the hero never renders empty.823 */824internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }825826internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =827    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)828829internal fun shortCredential(credentialId: String): String {830    if (credentialId.length <= 12) return credentialId831    return credentialId.take(6) + "..." + credentialId.takeLast(4)832}833834internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {835    val count = pingCount ?: 0836    val countLabel = "$count total alert" + if (count == 1) "" else "s"837    if (lastPinged == null) return countLabel838    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)839        .format(java.util.Date(lastPinged))840    return "Last triggered: $date · $countLabel"841}