EntryDetailScreen.kt

36.3 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.Check36import androidx.compose.material.icons.rounded.ContentCopy37import androidx.compose.material.icons.rounded.Search38import androidx.compose.foundation.verticalScroll39import androidx.compose.material.icons.Icons40import androidx.compose.material.icons.automirrored.rounded.ArrowBack41import androidx.compose.material.icons.automirrored.rounded.ArrowForward42import androidx.compose.material.icons.outlined.DeleteOutline43import 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.FaviconInitials79import com.peachpasswords.ui.components.PeachEmptyOrEndState80import com.peachpasswords.ui.components.PeachTopBar81import com.peachpasswords.ui.components.SecureFlagEffect82import com.peachpasswords.ui.components.TOTPRing83import com.peachpasswords.ui.lock.Biometrics84import com.peachpasswords.ui.theme.LocalPeachAccent85import com.peachpasswords.ui.theme.LocalReduceMotion86import com.peachpasswords.ui.theme.PeachSpacing87import 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    vm: EntryDetailViewModel = hiltViewModel(),107) {108    val entry by vm.entry.collectAsState()109    val activeEntries by vm.activeEntries.collectAsState()110    val identitySuggestions by vm.identitySuggestions.collectAsState()111    val allTags by vm.allTags.collectAsState()112    val isPro by vm.isPro.collectAsState()113    val cvcRevealed by vm.cvcRevealed.collectAsState()114    var cardRevealed by remember { mutableStateOf(false) }115    val copiedField by vm.copiedField.collectAsState()116    val canaryMessage by vm.canaryMessage.collectAsState()117    val canaryTesting by vm.canaryTesting.collectAsState()118    val context = LocalContext.current119    val accent = LocalPeachAccent.current120    var confirmingDelete by remember { mutableStateOf(false) }121    var generatorOpen by remember { mutableStateOf(false) }122    var historyExpanded by remember { mutableStateOf(false) }123124    SecureFlagEffect()125126    val e = entry127    ShellBackground {128        Box(129            Modifier.fillMaxSize().statusBarsPadding()130                // C1: swipe left/right between deck entries, mirroring the131                // prev/next arrows in EntryDetailSearchStrip. Resolved on132                // drag end (not per-event) so an in-progress vertical scroll133                // gesture on the content below can't accidentally trigger it.134                .pointerInput(vm) {135                    var dragAccum = 0f136                    detectHorizontalDragGestures(137                        onDragStart = { dragAccum = 0f },138                        onDragEnd = {139                            when {140                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)141                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)142                            }143                            dragAccum = 0f144                        },145                        onDragCancel = { dragAccum = 0f },146                    ) { _, dragAmount -> dragAccum += dragAmount }147                },148        ) {149            Column(150                Modifier.fillMaxSize()151                    .padding(bottom = 100.dp)152                    .verticalScroll(rememberScrollState())153                    .padding(horizontal = PeachSpacing.xl),154            ) {155                EntryDetailTopBar(156                    entry = e,157                    confirmingDelete = confirmingDelete,158                    onBack = onBack,159                    onLock = vm::lock,160                    onDelete = {161                        if (confirmingDelete) {162                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })163                        } else {164                            confirmingDelete = true165                        }166                    },167                )168                Spacer(Modifier.height(PeachSpacing.lg))169170                if (e == null) {171                    PeachEmptyOrEndState(172                        message = "entry not found",173                        detail = "It may have been moved or deleted.",174                        modifier = Modifier.padding(top = PeachSpacing.xxl),175                    )176                    return@Column177                }178179                // D11 — breach banner strip when this entry's password is known-compromised.180                if (e.isBreach()) {181                    Row(182                        Modifier.fillMaxWidth()183                            .background(peachColors.BreachBg)184                            .padding(horizontal = 16.dp, vertical = 8.dp),185                        verticalAlignment = Alignment.CenterVertically,186                    ) {187                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)188                        Spacer(Modifier.width(10.dp))189                        Text(190                            "Password found in a data breach — change it now",191                            color = peachColors.Breach, style = PeachType.labelMono,192                            modifier = Modifier.weight(1f),193                        )194                        Text(195                            "Change password",196                            color = accent, style = PeachType.labelMono,197                            modifier = Modifier.clip(RoundedCornerShape(6.dp))198                                .clickable { generatorOpen = true }199                                .padding(horizontal = 6.dp, vertical = 4.dp),200                        )201                    }202                }203                if (entryHasHomographWarning(e)) {204                    HomographWarning()205                }206207                // Hero — ghost PeachScript watermark behind the entry name. The full208                // title is drawn (not just its first letter); a long title overflows209                // and clips naturally, which is the intended watermark behavior.210                Box(Modifier.fillMaxWidth().clipToBounds()) {211                    Text(212                        text = ghostWatermarkText(e.title),213                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),214                        color = peachColors.T0.copy(alpha = 0.03f),215                        maxLines = 1,216                        softWrap = false,217                        overflow = TextOverflow.Clip,218                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),219                    )220                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {221                        EditableTitleHero(222                            title = e.title,223                            onSave = { vm.updateField("title", it) },224                        )225                    }226                }227                Spacer(Modifier.height(PeachSpacing.lg))228229                if (e.isCard()) {230                    CardDetailFields(231                        entry = e,232                        cardRevealed = cardRevealed,233                        cvcRevealed = cvcRevealed,234                        copiedField = copiedField,235                        onToggleCardReveal = { cardRevealed = !cardRevealed },236                        onRevealCvc = {237                            val activity = context as? FragmentActivity238                            if (activity != null && Biometrics.isAvailable(context)) {239                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)240                            } else vm.revealCvc()241                        },242                        onMaskCvc = vm::maskCvc,243                        onCopy = vm::copy,244                        onSaveCard = vm::updateCard,245                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },246                    )247                } else {248                    if (!e.isNote()) {249                        EditableFieldRow(250                            label = "url", field = "url", value = e.url.orEmpty(),251                            copied = copiedField == "url",252                            onCopy = { vm.copy("url", e.url) },253                            onSave = { vm.updateField("url", it) },254                            placeholder = "add url",255                        )256                    }257                    // Browser distinguishes USERNAME and EMAIL as separate258                    // optional fields, with a "+ add X" link for whichever the259                    // entry doesn't have yet — an entry can carry both. This260                    // used to just omit the row entirely for a blank field,261                    // with no way to add it from the detail screen.262                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }263                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }264                    if (showUsernameField) {265                        EditableFieldRow(266                            label = "username", field = "username", value = e.username.orEmpty(),267                            copied = copiedField == "username",268                            onCopy = { vm.copy("username", e.username) },269                            onSave = { vm.updateField("username", it) },270                            suggestions = identitySuggestions,271                            placeholder = "add username",272                        )273                    }274                    if (showEmailField) {275                        EditableFieldRow(276                            label = "email", field = "email", value = e.email.orEmpty(),277                            copied = copiedField == "email",278                            onCopy = { vm.copy("email", e.email) },279                            onSave = { vm.updateField("email", it) },280                            suggestions = identitySuggestions,281                            placeholder = "add email",282                        )283                    }284                    if (!showUsernameField || !showEmailField) {285                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {286                            if (!showUsernameField) {287                                Text(288                                    "+ add username", color = accent, style = PeachType.labelMono,289                                    modifier = Modifier290                                        .clip(RoundedCornerShape(6.dp))291                                        .clickable { showUsernameField = true }292                                        .padding(vertical = 6.dp),293                                )294                            }295                            if (!showEmailField) {296                                Text(297                                    "+ add email", color = accent, style = PeachType.labelMono,298                                    modifier = Modifier299                                        .clip(RoundedCornerShape(6.dp))300                                        .clickable { showEmailField = true }301                                        .padding(vertical = 6.dp),302                                )303                            }304                        }305                    }306                    AdditionalUrlRows(307                        urls = e.additionalUrls.orEmpty(),308                        copiedField = copiedField,309                        onCopy = { key, value -> vm.copy(key, value) },310                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },311                        onAdd = vm::addAdditionalUrl,312                    )313                    if (e.hasPasskey()) {314                        PasskeyDetailSection(e)315                    }316                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->317                        EditablePasswordRow(318                            value = pw,319                            copied = copiedField == "password",320                            entryTitle = e.title,321                            onCopy = { vm.copy("password", pw) },322                            onSave = { vm.updateField("password", it) },323                            onGenerate = { generatorOpen = true },324                        )325                    }326                    val history = e.passwordHistory.orEmpty()327                    if (history.isNotEmpty()) {328                        PasswordHistoryToggle(329                            count = history.size,330                            expanded = historyExpanded,331                            onToggle = { historyExpanded = !historyExpanded },332                        )333                        AnimatedVisibility(visible = historyExpanded) {334                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {335                                PasswordHistoryList(336                                    history = history,337                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },338                                    onCopy = { oldPw -> vm.copy("password", oldPw) },339                                )340                            }341                        }342                    }343                    if (e.hasTOTP()) {344                        Spacer(Modifier.height(4.dp))345                        TotpBandEditable(346                            totpField = e.totp!!,347                            copied = copiedField == "totp",348                            onCopy = vm::copyTotp,349                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },350                        )351                    }352                }353                e.notes?.takeIf { it.isNotBlank() }?.let { v ->354                    EditableFieldRow(355                        label = "notes", field = "notes", value = v,356                        copied = copiedField == "notes",357                        onCopy = { vm.copy("notes", v) },358                        onSave = { vm.updateField("notes", it) },359                        mono = false, singleLine = false,360                    )361                }362                DetailTagRow(363                    tags = e.tags.orEmpty(),364                    allTags = allTags,365                    onAddTag = vm::addTag,366                    onRemoveTag = vm::removeTag,367                )368                if (!e.isNote() && !e.isCard()) {369                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),370                    // unless a canary is already configured for this entry — every371                    // entry showing an "Off" canary block permanently was clutter372                    // most people never touch.373                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }374                    if (advancedExpanded) {375                        CanaryDetailSection(376                            entryId = e.id,377                            modifiedAt = e.modifiedAt,378                            canary = e.canary,379                            isPro = isPro,380                            message = canaryMessage,381                            testing = canaryTesting,382                            onSave = vm::saveCanary,383                            onTest = vm::testCanary,384                        )385                    } else {386                        Text(387                            "+ advanced",388                            color = LocalPeachAccent.current,389                            style = PeachType.labelMono,390                            modifier = Modifier391                                .clip(RoundedCornerShape(6.dp))392                                .clickable { advancedExpanded = true }393                                .padding(vertical = 6.dp),394                        )395                    }396                }397398                Spacer(Modifier.height(PeachSpacing.xxl))399            }400401            if (e != null) {402                EntryDetailSearchStrip(403                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },404                    totalEntries = activeEntries.size,405                    onOpenList = { onNavigate(PeachNav.VaultList) },406                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },407                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },408                    modifier = Modifier.align(Alignment.BottomCenter),409                )410            }411412            if (generatorOpen) {413                GeneratorSheet(414                    copied = copiedField == "genDraft",415                    onCopy = { draft -> vm.copy("genDraft", draft) },416                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },417                    onDismiss = { generatorOpen = false },418                )419            }420        }421    }422}423424@Composable425private fun EntryDetailTopBar(426    entry: com.peachpasswords.vault.VaultEntry?,427    confirmingDelete: Boolean,428    onBack: (() -> Unit)?,429    onLock: () -> Unit,430    onDelete: () -> Unit,431) {432    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }433    PeachTopBar(434        title = when {435            entry == null -> "entry"436            confirmingDelete -> "delete \"${entry.title}\"?"437            domain.isNotEmpty() -> domain438            else -> entry.title439        },440        leading = {441            if (onBack != null) {442                Icon(443                    Icons.AutoMirrored.Rounded.ArrowBack,444                    contentDescription = "back",445                    tint = peachColors.T0,446                    modifier = Modifier447                        .size(40.dp)448                        .clip(CircleShape)449                        .clickable(onClick = onBack)450                        .padding(PeachSpacing.sm),451                )452            } else {453                // This entry is the post-unlock landing surface, not pushed454                // from the vault list — there's nothing to go "back" to, so455                // the chrome offers the one action that actually makes sense456                // here instead (lock vault list's own affordance). Same457                // 40dp tap target as the back arrow, but the padlock glyph458                // reads visually heavier than the thin arrow chevron at the459                // same box size, so it gets a touch more inset to match.460                Icon(461                    Icons.Outlined.Lock,462                    contentDescription = "Lock vault",463                    tint = peachColors.T0,464                    modifier = Modifier465                        .size(40.dp)466                        .clip(CircleShape)467                        .clickable(onClick = onLock)468                        .padding(11.dp),469                )470            }471            if (entry != null && !confirmingDelete) {472                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)473            }474        },475        actions = {476            if (entry != null) {477                Icon(478                    Icons.Outlined.DeleteOutline,479                    contentDescription = "delete",480                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,481                    modifier = Modifier482                        .size(40.dp)483                        .clip(CircleShape)484                        .clickable(onClick = onDelete)485                        .padding(PeachSpacing.sm),486                )487            }488        },489    )490}491492@Composable493private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {494    val accent = LocalPeachAccent.current495    var editing by remember(title) { mutableStateOf(false) }496    var draft by remember(title) { mutableStateOf(title) }497    if (!editing) {498        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {499            CharEnterText(500                text = title,501                style = PeachType.entryNameHero.copy(color = peachColors.T0),502            )503        }504        return505    }506    Column(Modifier.fillMaxWidth()) {507        BasicTextField(508            value = draft,509            onValueChange = { draft = it },510            singleLine = true,511            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),512            cursorBrush = SolidColor(accent),513            modifier = Modifier.fillMaxWidth().drawBehind {514                drawLine(515                    color = accent,516                    start = Offset(0f, size.height),517                    end = Offset(size.width, size.height),518                    strokeWidth = 1.dp.toPx(),519                )520            },521        )522        Spacer(Modifier.height(8.dp))523        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {524            Text(525                "✓ save", color = accent, style = PeachType.labelMono,526                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {527                    onSave(draft)528                    editing = false529                }.padding(vertical = 4.dp),530            )531            Text(532                "✗", color = peachColors.T2, style = PeachType.labelMono,533                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {534                    draft = title535                    editing = false536                }.padding(horizontal = 6.dp, vertical = 4.dp),537            )538        }539    }540}541542@Composable543private fun HomographWarning() {544    Row(545        Modifier.fillMaxWidth()546            .padding(top = 8.dp)547            .clip(RoundedCornerShape(8.dp))548            .background(peachColors.BreachBg)549            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))550            .padding(horizontal = 12.dp, vertical = 9.dp),551        verticalAlignment = Alignment.CenterVertically,552    ) {553        Text("!", color = peachColors.Breach, style = PeachType.labelMono)554        Spacer(Modifier.width(10.dp))555        Text(556            "This URL contains non-ASCII or punycode characters.",557            color = peachColors.Breach,558            style = PeachType.labelMono,559            modifier = Modifier.weight(1f),560        )561    }562}563564@Composable565private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {566    val passkey = entry.passkey ?: return567    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {568        Column(Modifier.weight(1f)) {569            Text(570                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,571                color = peachColors.T0,572                style = PeachType.body,573            )574            Text(575                listOfNotNull(576                    passkey.rpId,577                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,578                ).joinToString(" · "),579                color = peachColors.T2,580                style = PeachType.labelMono,581            )582            Text(583                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",584                color = peachColors.T2,585                style = PeachType.labelMono,586                modifier = Modifier.padding(top = 3.dp),587            )588        }589    }590}591592@Composable593private fun CanaryDetailSection(594    entryId: String,595    modifiedAt: Long,596    canary: com.peachpasswords.vault.Canary?,597    isPro: Boolean,598    message: String?,599    testing: Boolean,600    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,601    onTest: (webhookUrl: String) -> Unit,602) {603    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }604    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }605    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }606    val gated = !isPro && canary == null607    FieldShell("canary alert") {608        Column(Modifier.weight(1f)) {609            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {610                Text(611                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",612                    color = peachColors.T0,613                    style = PeachType.body,614                    modifier = Modifier.weight(1f),615                )616                if (gated) {617                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)618                }619                Text(620                    if (enabled) "on" else "off",621                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,622                    style = PeachType.labelMono,623                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {624                        if (gated) {625                            onSave(true, webhookUrl, label)626                        } else {627                            val next = !enabled628                            enabled = next629                            if (!next) onSave(false, webhookUrl, label)630                        }631                    }.padding(horizontal = 6.dp, vertical = 4.dp),632                )633            }634            Text(635                "Fires a silent webhook when this entry's password or username is copied.",636                color = peachColors.T2,637                style = PeachType.labelMono,638                modifier = Modifier.padding(top = 3.dp),639            )640            if (enabled) {641                Spacer(Modifier.height(8.dp))642                PeachField(643                    label = "webhook url",644                    value = webhookUrl,645                    onChange = { webhookUrl = it },646                )647                Text(648                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",649                    color = peachColors.T2,650                    style = PeachType.labelMono,651                )652                PeachField(653                    label = "label",654                    value = label,655                    onChange = { label = it },656                    mono = false,657                )658                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {659                    Text(660                        "save",661                        color = LocalPeachAccent.current,662                        style = PeachType.labelMono,663                        modifier = Modifier.clip(RoundedCornerShape(6.dp))664                            .clickable { onSave(true, webhookUrl, label) }665                            .padding(vertical = 4.dp),666                    )667                    Text(668                        if (testing) "sending..." else "test ping",669                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,670                        style = PeachType.labelMono,671                        modifier = Modifier.clip(RoundedCornerShape(6.dp))672                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }673                            .padding(vertical = 4.dp),674                    )675                }676            }677            message?.let {678                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))679            }680            if (canary?.lastPinged != null || canary?.pingCount != null) {681                Text(682                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),683                    color = peachColors.T2,684                    style = PeachType.labelMono,685                    modifier = Modifier.padding(top = 3.dp),686                )687            }688        }689    }690}691692@Composable693private fun EntryDetailSearchStrip(694    currentIndex: Int,695    totalEntries: Int,696    onOpenList: () -> Unit,697    onPrevious: () -> Unit,698    onNext: () -> Unit,699    modifier: Modifier = Modifier,700) {701    val accent = LocalPeachAccent.current702    val hasEntries = totalEntries > 0 && currentIndex >= 0703    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"704705    Column(706        modifier707            .fillMaxWidth()708            .navigationBarsPadding()709            .padding(horizontal = 16.dp, vertical = 12.dp),710    ) {711        Row(712            Modifier713                .fillMaxWidth()714                .height(56.dp)715                .clip(RoundedCornerShape(16.dp))716                .background(peachColors.S1)717                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))718                .padding(horizontal = 12.dp),719            verticalAlignment = Alignment.CenterVertically,720        ) {721            Row(722                Modifier723                    .weight(1f)724                    .fillMaxHeight()725                    .clickable(onClick = onOpenList)726                    .pointerInput(onOpenList) {727                        var opened = false728                        detectVerticalDragGestures(729                            onDragEnd = { opened = false },730                            onDragCancel = { opened = false },731                        ) { _, dragAmount ->732                            if (!opened && dragAmount < -4f) {733                                opened = true734                                onOpenList()735                            }736                        }737                    },738                verticalAlignment = Alignment.CenterVertically,739            ) {740                Icon(741                    Icons.Rounded.Search,742                    contentDescription = null,743                    tint = accent,744                    modifier = Modifier.size(14.dp),745                )746                Spacer(Modifier.width(8.dp))747                Text(748                    "search vault...",749                    color = peachColors.T2,750                    style = PeachType.searchInput,751                    modifier = Modifier.weight(1f),752                )753                Text(754                    counter,755                    color = peachColors.T2,756                    style = PeachType.labelMono,757                )758            }759            Spacer(Modifier.width(8.dp))760            Icon(761                Icons.AutoMirrored.Rounded.ArrowBack,762                contentDescription = "Previous entry",763                tint = if (hasEntries) peachColors.T0 else peachColors.T2,764                modifier = Modifier765                    .size(38.dp)766                    .clip(CircleShape)767                    .clickable(enabled = hasEntries, onClick = onPrevious)768                    .padding(9.dp),769            )770            Icon(771                Icons.AutoMirrored.Rounded.ArrowForward,772                contentDescription = "Next entry",773                tint = if (hasEntries) peachColors.T0 else peachColors.T2,774                modifier = Modifier775                    .size(38.dp)776                    .clip(CircleShape)777                    .clickable(enabled = hasEntries, onClick = onNext)778                    .padding(9.dp),779            )780        }781    }782}783784/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */785@Composable786internal fun FieldShell(787    label: String,788    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,789) {790    val hairline = peachColors.T3791    Column(792        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {793            drawRect(794                color = hairline,795                topLeft = Offset(0f, size.height - 1.dp.toPx()),796                size = Size(size.width, 1.dp.toPx()),797            )798        },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/**807 * Text for the ghost PeachScript watermark behind the entry name: the full title,808 * falling back to "P" for a blank title so the hero never renders empty.809 */810internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }811812internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =813    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)814815internal fun shortCredential(credentialId: String): String {816    if (credentialId.length <= 12) return credentialId817    return credentialId.take(6) + "..." + credentialId.takeLast(4)818}819820internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {821    val count = pingCount ?: 0822    val countLabel = "$count total alert" + if (count == 1) "" else "s"823    if (lastPinged == null) return countLabel824    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)825        .format(java.util.Date(lastPinged))826    return "Last triggered: $date · $countLabel"827}828829private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {830    val raw = url831    if (!raw.isNullOrBlank()) {832        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')833        if (cleaned.isNotEmpty()) return cleaned834    }835    return ""836}