EntryDetailScreen.kt

37.9 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectHorizontalDragGestures15import androidx.compose.foundation.gestures.detectVerticalDragGestures16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.Column19import androidx.compose.foundation.layout.Row20import androidx.compose.foundation.layout.Spacer21import androidx.compose.foundation.layout.fillMaxSize22import androidx.compose.foundation.layout.fillMaxHeight23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.offset26import androidx.compose.foundation.layout.padding27import androidx.compose.foundation.layout.navigationBarsPadding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.layout.width31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.shape.CircleShape33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material.icons.rounded.Add36import androidx.compose.material.icons.rounded.Check37import androidx.compose.material.icons.rounded.ContentCopy38import androidx.compose.material.icons.rounded.Search39import androidx.compose.material.icons.rounded.Settings40import androidx.compose.foundation.verticalScroll41import androidx.compose.material.icons.Icons42import androidx.compose.material.icons.automirrored.rounded.ArrowBack43import androidx.compose.material.icons.automirrored.rounded.ArrowForward44import androidx.compose.material.icons.outlined.DeleteOutline45import androidx.compose.material.icons.outlined.Lock46import androidx.compose.material3.Icon47import androidx.compose.material3.Text48import androidx.compose.runtime.Composable49import androidx.compose.runtime.getValue50import androidx.compose.runtime.mutableStateOf51import androidx.compose.runtime.produceState52import androidx.compose.runtime.remember53import androidx.compose.runtime.setValue54import androidx.compose.ui.Alignment55import androidx.compose.ui.Modifier56import androidx.compose.ui.draw.clip57import androidx.compose.ui.draw.clipToBounds58import androidx.compose.ui.draw.drawBehind59import androidx.compose.ui.geometry.Offset60import androidx.compose.ui.geometry.Size61import androidx.compose.ui.graphics.Brush62import androidx.compose.ui.graphics.Color63import androidx.compose.ui.graphics.SolidColor64import androidx.compose.ui.graphics.graphicsLayer65import androidx.compose.ui.platform.LocalContext66import androidx.compose.ui.input.pointer.pointerInput67import androidx.compose.ui.text.TextStyle68import androidx.compose.ui.text.font.FontStyle69import androidx.compose.ui.text.style.TextOverflow70import androidx.compose.ui.unit.dp71import androidx.compose.ui.unit.em72import androidx.compose.ui.unit.sp73import androidx.compose.runtime.collectAsState74import androidx.fragment.app.FragmentActivity75import androidx.hilt.navigation.compose.hiltViewModel76import com.peachpasswords.shared.detectHomograph77import com.peachpasswords.ui.PeachNav78import com.peachpasswords.ui.components.CharEnterText79import com.peachpasswords.ui.components.CopyButton80import com.peachpasswords.ui.components.PeachEmptyOrEndState81import com.peachpasswords.ui.components.PeachTopBar82import com.peachpasswords.ui.components.SecureFlagEffect83import com.peachpasswords.ui.components.TOTPRing84import com.peachpasswords.ui.lock.Biometrics85import com.peachpasswords.ui.theme.LocalPeachAccent86import com.peachpasswords.ui.theme.LocalReduceMotion87import com.peachpasswords.ui.theme.PeachSpacing88import com.peachpasswords.ui.theme.peachColors89import com.peachpasswords.ui.theme.PeachType90import com.peachpasswords.ui.theme.ShellBackground91import com.peachpasswords.vault.TOTPGenerator92import com.peachpasswords.vault.hasTOTP93import com.peachpasswords.vault.hasPasskey94import com.peachpasswords.vault.isBreach95import com.peachpasswords.vault.isCard96import com.peachpasswords.vault.isNote97import com.peachpasswords.vault.isPasskeyOnly9899@Composable100fun EntryDetailScreen(101    id: String,102    onNavigate: (PeachNav) -> Unit,103    // Null when this entry is the post-unlock landing surface (C1: entry deck104    // is primary, not a child of the vault list) — there's genuinely nothing105    // to go "back" to, so the top chrome shows a lock affordance instead.106    onBack: (() -> Unit)?,107    vm: EntryDetailViewModel = hiltViewModel(),108) {109    val entry by vm.entry.collectAsState()110    val activeEntries by vm.activeEntries.collectAsState()111    val identitySuggestions by vm.identitySuggestions.collectAsState()112    val allTags by vm.allTags.collectAsState()113    val isPro by vm.isPro.collectAsState()114    val cvcRevealed by vm.cvcRevealed.collectAsState()115    var cardRevealed by remember { mutableStateOf(false) }116    val copiedField by vm.copiedField.collectAsState()117    val canaryMessage by vm.canaryMessage.collectAsState()118    val canaryTesting by vm.canaryTesting.collectAsState()119    val context = LocalContext.current120    val accent = LocalPeachAccent.current121    var confirmingDelete by remember { mutableStateOf(false) }122    var generatorOpen by remember { mutableStateOf(false) }123    var historyExpanded by remember { mutableStateOf(false) }124125    SecureFlagEffect()126127    val e = entry128    ShellBackground {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 = 100.dp)153                    .verticalScroll(rememberScrollState())154                    .padding(horizontal = PeachSpacing.xl),155            ) {156                EntryDetailTopBar(157                    entry = e,158                    confirmingDelete = confirmingDelete,159                    onBack = onBack,160                    onLock = vm::lock,161                    onDelete = {162                        if (confirmingDelete) {163                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })164                        } else {165                            confirmingDelete = true166                        }167                    },168                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },169                    onSettings = { onNavigate(PeachNav.Settings) },170                )171                Spacer(Modifier.height(PeachSpacing.lg))172173                if (e == null) {174                    PeachEmptyOrEndState(175                        message = "entry not found",176                        detail = "It may have been moved or deleted.",177                        modifier = Modifier.padding(top = PeachSpacing.xxl),178                    )179                    return@Column180                }181182                // D11 — breach banner strip when this entry's password is known-compromised.183                if (e.isBreach()) {184                    Row(185                        Modifier.fillMaxWidth()186                            .background(peachColors.BreachBg)187                            .padding(horizontal = 16.dp, vertical = 8.dp),188                        verticalAlignment = Alignment.CenterVertically,189                    ) {190                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)191                        Spacer(Modifier.width(10.dp))192                        Text(193                            "Password found in a data breach — change it now",194                            color = peachColors.Breach, style = PeachType.labelMono,195                            modifier = Modifier.weight(1f),196                        )197                        Text(198                            "Change password",199                            color = accent, style = PeachType.labelMono,200                            modifier = Modifier.clip(RoundedCornerShape(6.dp))201                                .clickable { generatorOpen = true }202                                .padding(horizontal = 6.dp, vertical = 4.dp),203                        )204                    }205                }206                if (entryHasHomographWarning(e)) {207                    HomographWarning()208                }209210                // Hero — ghost PeachScript watermark behind the entry name. The full211                // title is drawn (not just its first letter); a long title overflows212                // and clips naturally, which is the intended watermark behavior.213                Box(Modifier.fillMaxWidth().clipToBounds()) {214                    Text(215                        text = ghostWatermarkText(e.title),216                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),217                        color = peachColors.T0.copy(alpha = 0.03f),218                        maxLines = 1,219                        softWrap = false,220                        overflow = TextOverflow.Clip,221                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),222                    )223                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {224                        EditableTitleHero(225                            title = e.title,226                            onSave = { vm.updateField("title", it) },227                        )228                    }229                }230                Spacer(Modifier.height(PeachSpacing.lg))231232                if (e.isCard()) {233                    CardDetailFields(234                        entry = e,235                        cardRevealed = cardRevealed,236                        cvcRevealed = cvcRevealed,237                        copiedField = copiedField,238                        onToggleCardReveal = { cardRevealed = !cardRevealed },239                        onRevealCvc = {240                            val activity = context as? FragmentActivity241                            if (activity != null && Biometrics.isAvailable(context)) {242                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)243                            } else vm.revealCvc()244                        },245                        onMaskCvc = vm::maskCvc,246                        onCopy = vm::copy,247                        onSaveCard = vm::updateCard,248                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },249                    )250                } else {251                    if (!e.isNote()) {252                        EditableFieldRow(253                            label = "url", field = "url", value = e.url.orEmpty(),254                            copied = copiedField == "url",255                            onCopy = { vm.copy("url", e.url) },256                            onSave = { vm.updateField("url", it) },257                            placeholder = "add url",258                        )259                    }260                    // Browser distinguishes USERNAME and EMAIL as separate261                    // optional fields, with a "+ add X" link for whichever the262                    // entry doesn't have yet — an entry can carry both. This263                    // used to just omit the row entirely for a blank field,264                    // with no way to add it from the detail screen.265                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }266                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }267                    if (showUsernameField) {268                        EditableFieldRow(269                            label = "username", field = "username", value = e.username.orEmpty(),270                            copied = copiedField == "username",271                            onCopy = { vm.copy("username", e.username) },272                            onSave = { vm.updateField("username", it) },273                            suggestions = identitySuggestions,274                            placeholder = "add username",275                        )276                    }277                    if (showEmailField) {278                        EditableFieldRow(279                            label = "email", field = "email", value = e.email.orEmpty(),280                            copied = copiedField == "email",281                            onCopy = { vm.copy("email", e.email) },282                            onSave = { vm.updateField("email", it) },283                            suggestions = identitySuggestions,284                            placeholder = "add email",285                        )286                    }287                    if (!showUsernameField || !showEmailField) {288                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {289                            if (!showUsernameField) {290                                Text(291                                    "+ add username", color = accent, style = PeachType.labelMono,292                                    modifier = Modifier293                                        .clip(RoundedCornerShape(6.dp))294                                        .clickable { showUsernameField = true }295                                        .padding(vertical = 6.dp),296                                )297                            }298                            if (!showEmailField) {299                                Text(300                                    "+ add email", color = accent, style = PeachType.labelMono,301                                    modifier = Modifier302                                        .clip(RoundedCornerShape(6.dp))303                                        .clickable { showEmailField = true }304                                        .padding(vertical = 6.dp),305                                )306                            }307                        }308                    }309                    AdditionalUrlRows(310                        urls = e.additionalUrls.orEmpty(),311                        copiedField = copiedField,312                        onCopy = { key, value -> vm.copy(key, value) },313                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },314                        onAdd = vm::addAdditionalUrl,315                    )316                    if (e.hasPasskey()) {317                        PasskeyDetailSection(e)318                    }319                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->320                        EditablePasswordRow(321                            value = pw,322                            copied = copiedField == "password",323                            entryTitle = e.title,324                            onCopy = { vm.copy("password", pw) },325                            onSave = { vm.updateField("password", it) },326                            onGenerate = { generatorOpen = true },327                        )328                    }329                    val history = e.passwordHistory.orEmpty()330                    if (history.isNotEmpty()) {331                        PasswordHistoryToggle(332                            count = history.size,333                            expanded = historyExpanded,334                            onToggle = { historyExpanded = !historyExpanded },335                        )336                        AnimatedVisibility(visible = historyExpanded) {337                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {338                                PasswordHistoryList(339                                    history = history,340                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },341                                    onCopy = { oldPw -> vm.copy("password", oldPw) },342                                )343                            }344                        }345                    }346                    if (e.hasTOTP()) {347                        Spacer(Modifier.height(4.dp))348                        TotpBandEditable(349                            totpField = e.totp!!,350                            copied = copiedField == "totp",351                            onCopy = vm::copyTotp,352                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },353                        )354                    }355                }356                e.notes?.takeIf { it.isNotBlank() }?.let { v ->357                    EditableFieldRow(358                        label = "notes", field = "notes", value = v,359                        copied = copiedField == "notes",360                        onCopy = { vm.copy("notes", v) },361                        onSave = { vm.updateField("notes", it) },362                        mono = false, singleLine = false,363                    )364                }365                DetailTagRow(366                    tags = e.tags.orEmpty(),367                    allTags = allTags,368                    onAddTag = vm::addTag,369                    onRemoveTag = vm::removeTag,370                )371                if (!e.isNote() && !e.isCard()) {372                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),373                    // unless a canary is already configured for this entry — every374                    // entry showing an "Off" canary block permanently was clutter375                    // most people never touch.376                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }377                    if (advancedExpanded) {378                        CanaryDetailSection(379                            entryId = e.id,380                            modifiedAt = e.modifiedAt,381                            canary = e.canary,382                            isPro = isPro,383                            message = canaryMessage,384                            testing = canaryTesting,385                            onSave = vm::saveCanary,386                            onTest = vm::testCanary,387                        )388                    } else {389                        Text(390                            "+ advanced",391                            color = LocalPeachAccent.current,392                            style = PeachType.labelMono,393                            modifier = Modifier394                                .clip(RoundedCornerShape(6.dp))395                                .clickable { advancedExpanded = true }396                                .padding(vertical = 6.dp),397                        )398                    }399                }400401                Spacer(Modifier.height(PeachSpacing.xxl))402            }403404            if (e != null) {405                EntryDetailSearchStrip(406                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },407                    totalEntries = activeEntries.size,408                    onOpenList = { onNavigate(PeachNav.VaultList) },409                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },410                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },411                    modifier = Modifier.align(Alignment.BottomCenter),412                )413            }414415            if (generatorOpen) {416                GeneratorSheet(417                    copied = copiedField == "genDraft",418                    onCopy = { draft -> vm.copy("genDraft", draft) },419                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },420                    onDismiss = { generatorOpen = false },421                )422            }423        }424    }425}426427@Composable428private fun EntryDetailTopBar(429    entry: com.peachpasswords.vault.VaultEntry?,430    confirmingDelete: Boolean,431    onBack: (() -> Unit)?,432    onLock: () -> Unit,433    onDelete: () -> Unit,434    onAddEntry: () -> Unit,435    onSettings: () -> Unit,436) {437    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }438    PeachTopBar(439        title = when {440            entry == null -> "entry"441            confirmingDelete -> "delete \"${entry.title}\"?"442            domain.isNotEmpty() -> domain443            else -> entry.title444        },445        leading = {446            if (onBack != null) {447                Icon(448                    Icons.AutoMirrored.Rounded.ArrowBack,449                    contentDescription = "back",450                    tint = peachColors.T1,451                    modifier = Modifier452                        .size(40.dp)453                        .clip(CircleShape)454                        .clickable(onClick = onBack)455                        .padding(PeachSpacing.sm),456                )457            } else {458                // This entry is the post-unlock landing surface, not pushed459                // from the vault list — there's nothing to go "back" to, so460                // the chrome offers the one action that actually makes sense461                // here instead (lock vault list's own affordance). Same 40dp462                // tap target and padding as every other icon in this bar.463                Icon(464                    Icons.Outlined.Lock,465                    contentDescription = "Lock vault",466                    tint = peachColors.T1,467                    modifier = Modifier468                        .size(40.dp)469                        .clip(CircleShape)470                        .clickable(onClick = onLock)471                        .padding(PeachSpacing.sm),472                )473            }474            // The favicon chip used to sit here, but it's the one loud,475            // colorful element in an otherwise quiet/muted bar (matching the476            // browser's persistent header, which carries no per-entry color477            // at all) — and it's redundant with the entry's own hero section478            // just below, which already shows its identity clearly.479        },480        actions = {481            // This entry is the deck root (no back target) — it's the closest482            // thing this app has to a "home" screen, so it's where the483            // persistent add/settings actions live now. They used to sit in a484            // bar on the vault list, but that's a sheet reached by sliding up485            // from here (C1), not a screen of its own anymore.486            if (onBack == null) {487                Icon(488                    Icons.Rounded.Add,489                    contentDescription = "new entry",490                    tint = peachColors.T1,491                    modifier = Modifier492                        .size(40.dp)493                        .clip(CircleShape)494                        .clickable(onClick = onAddEntry)495                        .padding(PeachSpacing.sm),496                )497                Icon(498                    Icons.Rounded.Settings,499                    contentDescription = "settings",500                    tint = peachColors.T1,501                    modifier = Modifier502                        .size(40.dp)503                        .clip(CircleShape)504                        .clickable(onClick = onSettings)505                        .padding(PeachSpacing.sm),506                )507            }508            if (entry != null) {509                Icon(510                    Icons.Outlined.DeleteOutline,511                    contentDescription = "delete",512                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,513                    modifier = Modifier514                        .size(40.dp)515                        .clip(CircleShape)516                        .clickable(onClick = onDelete)517                        .padding(PeachSpacing.sm),518                )519            }520        },521    )522}523524@Composable525private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {526    val accent = LocalPeachAccent.current527    var editing by remember(title) { mutableStateOf(false) }528    var draft by remember(title) { mutableStateOf(title) }529    if (!editing) {530        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {531            CharEnterText(532                text = title,533                style = PeachType.entryNameHero.copy(color = peachColors.T0),534            )535        }536        return537    }538    Column(Modifier.fillMaxWidth()) {539        BasicTextField(540            value = draft,541            onValueChange = { draft = it },542            singleLine = true,543            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),544            cursorBrush = SolidColor(accent),545            modifier = Modifier.fillMaxWidth().drawBehind {546                drawLine(547                    color = accent,548                    start = Offset(0f, size.height),549                    end = Offset(size.width, size.height),550                    strokeWidth = 1.dp.toPx(),551                )552            },553        )554        Spacer(Modifier.height(8.dp))555        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {556            Text(557                "✓ save", color = accent, style = PeachType.labelMono,558                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {559                    onSave(draft)560                    editing = false561                }.padding(vertical = 4.dp),562            )563            Text(564                "✗", color = peachColors.T2, style = PeachType.labelMono,565                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {566                    draft = title567                    editing = false568                }.padding(horizontal = 6.dp, vertical = 4.dp),569            )570        }571    }572}573574@Composable575private fun HomographWarning() {576    Row(577        Modifier.fillMaxWidth()578            .padding(top = 8.dp)579            .clip(RoundedCornerShape(8.dp))580            .background(peachColors.BreachBg)581            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))582            .padding(horizontal = 12.dp, vertical = 9.dp),583        verticalAlignment = Alignment.CenterVertically,584    ) {585        Text("!", color = peachColors.Breach, style = PeachType.labelMono)586        Spacer(Modifier.width(10.dp))587        Text(588            "This URL contains non-ASCII or punycode characters.",589            color = peachColors.Breach,590            style = PeachType.labelMono,591            modifier = Modifier.weight(1f),592        )593    }594}595596@Composable597private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {598    val passkey = entry.passkey ?: return599    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {600        Column(Modifier.weight(1f)) {601            Text(602                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,603                color = peachColors.T0,604                style = PeachType.body,605            )606            Text(607                listOfNotNull(608                    passkey.rpId,609                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,610                ).joinToString(" · "),611                color = peachColors.T2,612                style = PeachType.labelMono,613            )614            Text(615                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",616                color = peachColors.T2,617                style = PeachType.labelMono,618                modifier = Modifier.padding(top = 3.dp),619            )620        }621    }622}623624@Composable625private fun CanaryDetailSection(626    entryId: String,627    modifiedAt: Long,628    canary: com.peachpasswords.vault.Canary?,629    isPro: Boolean,630    message: String?,631    testing: Boolean,632    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,633    onTest: (webhookUrl: String) -> Unit,634) {635    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }636    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }637    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }638    val gated = !isPro && canary == null639    FieldShell("canary alert") {640        Column(Modifier.weight(1f)) {641            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {642                Text(643                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",644                    color = peachColors.T0,645                    style = PeachType.body,646                    modifier = Modifier.weight(1f),647                )648                if (gated) {649                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)650                }651                Text(652                    if (enabled) "on" else "off",653                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,654                    style = PeachType.labelMono,655                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {656                        if (gated) {657                            onSave(true, webhookUrl, label)658                        } else {659                            val next = !enabled660                            enabled = next661                            if (!next) onSave(false, webhookUrl, label)662                        }663                    }.padding(horizontal = 6.dp, vertical = 4.dp),664                )665            }666            Text(667                "Fires a silent webhook when this entry's password or username is copied.",668                color = peachColors.T2,669                style = PeachType.labelMono,670                modifier = Modifier.padding(top = 3.dp),671            )672            if (enabled) {673                Spacer(Modifier.height(8.dp))674                PeachField(675                    label = "webhook url",676                    value = webhookUrl,677                    onChange = { webhookUrl = it },678                )679                Text(680                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",681                    color = peachColors.T2,682                    style = PeachType.labelMono,683                )684                PeachField(685                    label = "label",686                    value = label,687                    onChange = { label = it },688                    mono = false,689                )690                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {691                    Text(692                        "save",693                        color = LocalPeachAccent.current,694                        style = PeachType.labelMono,695                        modifier = Modifier.clip(RoundedCornerShape(6.dp))696                            .clickable { onSave(true, webhookUrl, label) }697                            .padding(vertical = 4.dp),698                    )699                    Text(700                        if (testing) "sending..." else "test ping",701                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,702                        style = PeachType.labelMono,703                        modifier = Modifier.clip(RoundedCornerShape(6.dp))704                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }705                            .padding(vertical = 4.dp),706                    )707                }708            }709            message?.let {710                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))711            }712            if (canary?.lastPinged != null || canary?.pingCount != null) {713                Text(714                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),715                    color = peachColors.T2,716                    style = PeachType.labelMono,717                    modifier = Modifier.padding(top = 3.dp),718                )719            }720        }721    }722}723724@Composable725private fun EntryDetailSearchStrip(726    currentIndex: Int,727    totalEntries: Int,728    onOpenList: () -> Unit,729    onPrevious: () -> Unit,730    onNext: () -> Unit,731    modifier: Modifier = Modifier,732) {733    val accent = LocalPeachAccent.current734    val hasEntries = totalEntries > 0 && currentIndex >= 0735    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"736737    Column(738        modifier739            .fillMaxWidth()740            .navigationBarsPadding()741            .padding(horizontal = 16.dp, vertical = 12.dp),742    ) {743        Row(744            Modifier745                .fillMaxWidth()746                .height(56.dp)747                .clip(RoundedCornerShape(16.dp))748                .background(peachColors.S1)749                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))750                .padding(horizontal = 12.dp),751            verticalAlignment = Alignment.CenterVertically,752        ) {753            Row(754                Modifier755                    .weight(1f)756                    .fillMaxHeight()757                    .clickable(onClick = onOpenList)758                    .pointerInput(onOpenList) {759                        var opened = false760                        detectVerticalDragGestures(761                            onDragEnd = { opened = false },762                            onDragCancel = { opened = false },763                        ) { _, dragAmount ->764                            if (!opened && dragAmount < -4f) {765                                opened = true766                                onOpenList()767                            }768                        }769                    },770                verticalAlignment = Alignment.CenterVertically,771            ) {772                Icon(773                    Icons.Rounded.Search,774                    contentDescription = null,775                    tint = accent,776                    modifier = Modifier.size(14.dp),777                )778                Spacer(Modifier.width(8.dp))779                Text(780                    "search vault...",781                    color = peachColors.T2,782                    style = PeachType.searchInput,783                    modifier = Modifier.weight(1f),784                )785                Text(786                    counter,787                    color = peachColors.T2,788                    style = PeachType.labelMono,789                )790            }791            Spacer(Modifier.width(8.dp))792            Icon(793                Icons.AutoMirrored.Rounded.ArrowBack,794                contentDescription = "Previous entry",795                tint = if (hasEntries) peachColors.T0 else peachColors.T2,796                modifier = Modifier797                    .size(38.dp)798                    .clip(CircleShape)799                    .clickable(enabled = hasEntries, onClick = onPrevious)800                    .padding(9.dp),801            )802            Icon(803                Icons.AutoMirrored.Rounded.ArrowForward,804                contentDescription = "Next entry",805                tint = if (hasEntries) peachColors.T0 else peachColors.T2,806                modifier = Modifier807                    .size(38.dp)808                    .clip(CircleShape)809                    .clickable(enabled = hasEntries, onClick = onNext)810                    .padding(9.dp),811            )812        }813    }814}815816/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */817@Composable818internal fun FieldShell(819    label: String,820    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,821) {822    val hairline = peachColors.T3823    Column(824        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {825            drawRect(826                color = hairline,827                topLeft = Offset(0f, size.height - 1.dp.toPx()),828                size = Size(size.width, 1.dp.toPx()),829            )830        },831    ) {832        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))833        Spacer(Modifier.height(6.dp))834        Row(verticalAlignment = Alignment.CenterVertically, content = content)835    }836}837838/**839 * Text for the ghost PeachScript watermark behind the entry name: the full title,840 * falling back to "P" for a blank title so the hero never renders empty.841 */842internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }843844internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =845    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)846847internal fun shortCredential(credentialId: String): String {848    if (credentialId.length <= 12) return credentialId849    return credentialId.take(6) + "..." + credentialId.takeLast(4)850}851852internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {853    val count = pingCount ?: 0854    val countLabel = "$count total alert" + if (count == 1) "" else "s"855    if (lastPinged == null) return countLabel856    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)857        .format(java.util.Date(lastPinged))858    return "Last triggered: $date · $countLabel"859}860861private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {862    val raw = url863    if (!raw.isNullOrBlank()) {864        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')865        if (cleaned.isNotEmpty()) return cleaned866    }867    return ""868}