EntryDetailScreen.kt

38.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.automirrored.outlined.Notes45import androidx.compose.material.icons.outlined.DeleteOutline46import androidx.compose.material.icons.outlined.Lock47import androidx.compose.material3.Icon48import androidx.compose.material3.Text49import androidx.compose.runtime.Composable50import androidx.compose.runtime.getValue51import androidx.compose.runtime.mutableStateOf52import androidx.compose.runtime.produceState53import androidx.compose.runtime.remember54import androidx.compose.runtime.setValue55import androidx.compose.ui.Alignment56import androidx.compose.ui.Modifier57import androidx.compose.ui.draw.clip58import androidx.compose.ui.draw.clipToBounds59import androidx.compose.ui.draw.drawBehind60import androidx.compose.ui.geometry.Offset61import androidx.compose.ui.geometry.Size62import androidx.compose.ui.graphics.Brush63import androidx.compose.ui.graphics.Color64import androidx.compose.ui.graphics.SolidColor65import androidx.compose.ui.graphics.graphicsLayer66import androidx.compose.ui.platform.LocalContext67import androidx.compose.ui.input.pointer.pointerInput68import androidx.compose.ui.text.TextStyle69import androidx.compose.ui.text.font.FontStyle70import androidx.compose.ui.text.style.TextOverflow71import androidx.compose.ui.unit.dp72import androidx.compose.ui.unit.em73import androidx.compose.ui.unit.sp74import androidx.compose.runtime.collectAsState75import androidx.fragment.app.FragmentActivity76import androidx.hilt.navigation.compose.hiltViewModel77import com.peachpasswords.shared.detectHomograph78import com.peachpasswords.ui.PeachNav79import com.peachpasswords.ui.components.CharEnterText80import com.peachpasswords.ui.components.CopyButton81import com.peachpasswords.ui.components.PeachEmptyOrEndState82import com.peachpasswords.ui.components.PeachMark83import com.peachpasswords.ui.components.PeachTopBar84import com.peachpasswords.ui.components.SecureFlagEffect85import com.peachpasswords.ui.components.TOTPRing86import com.peachpasswords.ui.lock.Biometrics87import com.peachpasswords.ui.theme.LocalPeachAccent88import com.peachpasswords.ui.theme.LocalReduceMotion89import com.peachpasswords.ui.theme.PeachSpacing90import com.peachpasswords.ui.theme.peachColors91import com.peachpasswords.ui.theme.PeachType92import com.peachpasswords.ui.theme.ShellBackground93import com.peachpasswords.vault.TOTPGenerator94import com.peachpasswords.vault.hasTOTP95import com.peachpasswords.vault.hasPasskey96import com.peachpasswords.vault.isBreach97import com.peachpasswords.vault.isCard98import com.peachpasswords.vault.isNote99import com.peachpasswords.vault.isPasskeyOnly100101@Composable102fun EntryDetailScreen(103    id: String,104    onNavigate: (PeachNav) -> Unit,105    // Null when this entry is the post-unlock landing surface (C1: entry deck106    // is primary, not a child of the vault list) — there's genuinely nothing107    // to go "back" to, so the top chrome shows a lock affordance instead.108    onBack: (() -> Unit)?,109    vm: EntryDetailViewModel = hiltViewModel(),110) {111    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    SecureFlagEffect()128129    val e = entry130    ShellBackground {131        Box(132            Modifier.fillMaxSize().statusBarsPadding()133                // C1: swipe left/right between deck entries, mirroring the134                // prev/next arrows in EntryDetailSearchStrip. Resolved on135                // drag end (not per-event) so an in-progress vertical scroll136                // gesture on the content below can't accidentally trigger it.137                .pointerInput(vm) {138                    var dragAccum = 0f139                    detectHorizontalDragGestures(140                        onDragStart = { dragAccum = 0f },141                        onDragEnd = {142                            when {143                                dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)144                                dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)145                            }146                            dragAccum = 0f147                        },148                        onDragCancel = { dragAccum = 0f },149                    ) { _, dragAmount -> dragAccum += dragAmount }150                },151        ) {152            Column(153                Modifier.fillMaxSize()154                    .padding(bottom = 100.dp)155                    .verticalScroll(rememberScrollState())156                    .padding(horizontal = PeachSpacing.xl),157            ) {158                EntryDetailTopBar(159                    entry = e,160                    confirmingDelete = confirmingDelete,161                    onBack = onBack,162                    onLock = vm::lock,163                    onDelete = {164                        if (confirmingDelete) {165                            vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })166                        } else {167                            confirmingDelete = true168                        }169                    },170                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },171                    onSettings = { onNavigate(PeachNav.Settings) },172                    onOpenNotes = { onNavigate(PeachNav.VaultNotes) },173                )174                Spacer(Modifier.height(PeachSpacing.lg))175176                if (e == null) {177                    PeachEmptyOrEndState(178                        message = "entry not found",179                        detail = "It may have been moved or deleted.",180                        modifier = Modifier.padding(top = PeachSpacing.xxl),181                    )182                    return@Column183                }184185                // D11 — breach banner strip when this entry's password is known-compromised.186                if (e.isBreach()) {187                    Row(188                        Modifier.fillMaxWidth()189                            .background(peachColors.BreachBg)190                            .padding(horizontal = 16.dp, vertical = 8.dp),191                        verticalAlignment = Alignment.CenterVertically,192                    ) {193                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)194                        Spacer(Modifier.width(10.dp))195                        Text(196                            "Password found in a data breach — change it now",197                            color = peachColors.Breach, style = PeachType.labelMono,198                            modifier = Modifier.weight(1f),199                        )200                        Text(201                            "Change password",202                            color = accent, style = PeachType.labelMono,203                            modifier = Modifier.clip(RoundedCornerShape(6.dp))204                                .clickable { generatorOpen = true }205                                .padding(horizontal = 6.dp, vertical = 4.dp),206                        )207                    }208                }209                if (entryHasHomographWarning(e)) {210                    HomographWarning()211                }212213                // Hero — ghost PeachScript watermark behind the entry name. The full214                // title is drawn (not just its first letter); a long title overflows215                // and clips naturally, which is the intended watermark behavior.216                Box(Modifier.fillMaxWidth().clipToBounds()) {217                    Text(218                        text = ghostWatermarkText(e.title),219                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),220                        color = peachColors.T0.copy(alpha = 0.03f),221                        maxLines = 1,222                        softWrap = false,223                        overflow = TextOverflow.Clip,224                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),225                    )226                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {227                        EditableTitleHero(228                            title = e.title,229                            onSave = { vm.updateField("title", it) },230                        )231                    }232                }233                Spacer(Modifier.height(PeachSpacing.lg))234235                if (e.isCard()) {236                    CardDetailFields(237                        entry = e,238                        cardRevealed = cardRevealed,239                        cvcRevealed = cvcRevealed,240                        copiedField = copiedField,241                        onToggleCardReveal = { cardRevealed = !cardRevealed },242                        onRevealCvc = {243                            val activity = context as? FragmentActivity244                            if (activity != null && Biometrics.isAvailable(context)) {245                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)246                            } else vm.revealCvc()247                        },248                        onMaskCvc = vm::maskCvc,249                        onCopy = vm::copy,250                        onSaveCard = vm::updateCard,251                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },252                    )253                } else {254                    if (!e.isNote()) {255                        EditableFieldRow(256                            label = "url", field = "url", value = e.url.orEmpty(),257                            copied = copiedField == "url",258                            onCopy = { vm.copy("url", e.url) },259                            onSave = { vm.updateField("url", it) },260                            placeholder = "add url",261                        )262                    }263                    // Browser distinguishes USERNAME and EMAIL as separate264                    // optional fields, with a "+ add X" link for whichever the265                    // entry doesn't have yet — an entry can carry both. This266                    // used to just omit the row entirely for a blank field,267                    // with no way to add it from the detail screen.268                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }269                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }270                    if (showUsernameField) {271                        EditableFieldRow(272                            label = "username", field = "username", value = e.username.orEmpty(),273                            copied = copiedField == "username",274                            onCopy = { vm.copy("username", e.username) },275                            onSave = { vm.updateField("username", it) },276                            suggestions = identitySuggestions,277                            placeholder = "add username",278                        )279                    }280                    if (showEmailField) {281                        EditableFieldRow(282                            label = "email", field = "email", value = e.email.orEmpty(),283                            copied = copiedField == "email",284                            onCopy = { vm.copy("email", e.email) },285                            onSave = { vm.updateField("email", it) },286                            suggestions = identitySuggestions,287                            placeholder = "add email",288                        )289                    }290                    if (!showUsernameField || !showEmailField) {291                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {292                            if (!showUsernameField) {293                                Text(294                                    "+ add username", color = accent, style = PeachType.labelMono,295                                    modifier = Modifier296                                        .clip(RoundedCornerShape(6.dp))297                                        .clickable { showUsernameField = true }298                                        .padding(vertical = 6.dp),299                                )300                            }301                            if (!showEmailField) {302                                Text(303                                    "+ add email", color = accent, style = PeachType.labelMono,304                                    modifier = Modifier305                                        .clip(RoundedCornerShape(6.dp))306                                        .clickable { showEmailField = true }307                                        .padding(vertical = 6.dp),308                                )309                            }310                        }311                    }312                    AdditionalUrlRows(313                        urls = e.additionalUrls.orEmpty(),314                        copiedField = copiedField,315                        onCopy = { key, value -> vm.copy(key, value) },316                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },317                        onAdd = vm::addAdditionalUrl,318                    )319                    if (e.hasPasskey()) {320                        PasskeyDetailSection(e)321                    }322                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->323                        EditablePasswordRow(324                            value = pw,325                            copied = copiedField == "password",326                            entryTitle = e.title,327                            onCopy = { vm.copy("password", pw) },328                            onSave = { vm.updateField("password", it) },329                            onGenerate = { generatorOpen = true },330                        )331                    }332                    val history = e.passwordHistory.orEmpty()333                    if (history.isNotEmpty()) {334                        PasswordHistoryToggle(335                            count = history.size,336                            expanded = historyExpanded,337                            onToggle = { historyExpanded = !historyExpanded },338                        )339                        AnimatedVisibility(visible = historyExpanded) {340                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {341                                PasswordHistoryList(342                                    history = history,343                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },344                                    onCopy = { oldPw -> vm.copy("password", oldPw) },345                                )346                            }347                        }348                    }349                    if (e.hasTOTP()) {350                        Spacer(Modifier.height(4.dp))351                        TotpBandEditable(352                            totpField = e.totp!!,353                            copied = copiedField == "totp",354                            onCopy = vm::copyTotp,355                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },356                        )357                    }358                }359                e.notes?.takeIf { it.isNotBlank() }?.let { v ->360                    EditableFieldRow(361                        label = "notes", field = "notes", value = v,362                        copied = copiedField == "notes",363                        onCopy = { vm.copy("notes", v) },364                        onSave = { vm.updateField("notes", it) },365                        mono = false, singleLine = false,366                    )367                }368                DetailTagRow(369                    tags = e.tags.orEmpty(),370                    allTags = allTags,371                    onAddTag = vm::addTag,372                    onRemoveTag = vm::removeTag,373                )374                if (!e.isNote() && !e.isCard()) {375                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),376                    // unless a canary is already configured for this entry — every377                    // entry showing an "Off" canary block permanently was clutter378                    // most people never touch.379                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }380                    if (advancedExpanded) {381                        CanaryDetailSection(382                            entryId = e.id,383                            modifiedAt = e.modifiedAt,384                            canary = e.canary,385                            isPro = isPro,386                            message = canaryMessage,387                            testing = canaryTesting,388                            onSave = vm::saveCanary,389                            onTest = vm::testCanary,390                        )391                    } else {392                        Text(393                            "+ advanced",394                            color = LocalPeachAccent.current,395                            style = PeachType.labelMono,396                            modifier = Modifier397                                .clip(RoundedCornerShape(6.dp))398                                .clickable { advancedExpanded = true }399                                .padding(vertical = 6.dp),400                        )401                    }402                }403404                Spacer(Modifier.height(PeachSpacing.xxl))405            }406407            if (e != null) {408                EntryDetailSearchStrip(409                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },410                    totalEntries = activeEntries.size,411                    onOpenList = { onNavigate(PeachNav.VaultList) },412                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },413                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },414                    modifier = Modifier.align(Alignment.BottomCenter),415                )416            }417418            if (generatorOpen) {419                GeneratorSheet(420                    copied = copiedField == "genDraft",421                    onCopy = { draft -> vm.copy("genDraft", draft) },422                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },423                    onDismiss = { generatorOpen = false },424                )425            }426        }427    }428}429430@Composable431private fun EntryDetailTopBar(432    entry: com.peachpasswords.vault.VaultEntry?,433    confirmingDelete: Boolean,434    onBack: (() -> Unit)?,435    onLock: () -> Unit,436    onDelete: () -> Unit,437    onAddEntry: () -> Unit,438    onSettings: () -> Unit,439    onOpenNotes: () -> Unit,440) {441    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }442    PeachTopBar(443        title = when {444            confirmingDelete && entry != null -> "delete \"${entry.title}\"?"445            // Deck root (no back target) mirrors the browser's persistent446            // header, which always shows the "peach" wordmark — never the447            // current entry's identity — leaving PeachMark in `leading` below448            // to complete the wordmark pairing (see LockScreen's same pattern).449            onBack == null -> "peach"450            entry == null -> "entry"451            domain.isNotEmpty() -> domain452            else -> entry.title453        },454        leading = {455            if (onBack != null) {456                Icon(457                    Icons.AutoMirrored.Rounded.ArrowBack,458                    contentDescription = "back",459                    tint = peachColors.T1,460                    modifier = Modifier461                        .size(40.dp)462                        .clip(CircleShape)463                        .clickable(onClick = onBack)464                        .padding(PeachSpacing.sm),465                )466            } else {467                PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))468            }469            // The favicon chip used to sit here, but it's the one loud,470            // colorful element in an otherwise quiet/muted bar (matching the471            // browser's persistent header, which carries no per-entry color472            // at all) — and it's redundant with the entry's own hero section473            // just below, which already shows its identity clearly.474        },475        actions = {476            // This entry is the deck root (no back target) — it's the closest477            // thing this app has to a "home" screen, so it's where the478            // persistent chrome actions live now, mirroring the browser's479            // peach-chrome header exactly: Notes, Settings, New entry, then480            // Lock last/trailing (peach-browser/src/popup/App.tsx ~1347-1408).481            // Delete has no browser-chrome equivalent (it lives in the entry's482            // own view there) — kept here, grouped with the other483            // content-mutating actions just before the terminal Lock action.484            if (onBack == null) {485                Icon(486                    Icons.AutoMirrored.Outlined.Notes,487                    contentDescription = "Notes",488                    tint = peachColors.T1,489                    modifier = Modifier490                        .size(40.dp)491                        .clip(CircleShape)492                        .clickable(onClick = onOpenNotes)493                        .padding(PeachSpacing.sm),494                )495                Icon(496                    Icons.Rounded.Settings,497                    contentDescription = "settings",498                    tint = peachColors.T1,499                    modifier = Modifier500                        .size(40.dp)501                        .clip(CircleShape)502                        .clickable(onClick = onSettings)503                        .padding(PeachSpacing.sm),504                )505                Icon(506                    Icons.Rounded.Add,507                    contentDescription = "new entry",508                    tint = peachColors.T1,509                    modifier = Modifier510                        .size(40.dp)511                        .clip(CircleShape)512                        .clickable(onClick = onAddEntry)513                        .padding(PeachSpacing.sm),514                )515            }516            if (entry != null) {517                Icon(518                    Icons.Outlined.DeleteOutline,519                    contentDescription = "delete",520                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,521                    modifier = Modifier522                        .size(40.dp)523                        .clip(CircleShape)524                        .clickable(onClick = onDelete)525                        .padding(PeachSpacing.sm),526                )527            }528            if (onBack == null) {529                Icon(530                    Icons.Outlined.Lock,531                    contentDescription = "Lock vault",532                    tint = peachColors.T1,533                    modifier = Modifier534                        .size(40.dp)535                        .clip(CircleShape)536                        .clickable(onClick = onLock)537                        .padding(PeachSpacing.sm),538                )539            }540        },541    )542}543544@Composable545private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {546    val accent = LocalPeachAccent.current547    var editing by remember(title) { mutableStateOf(false) }548    var draft by remember(title) { mutableStateOf(title) }549    if (!editing) {550        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {551            CharEnterText(552                text = title,553                style = PeachType.entryNameHero.copy(color = peachColors.T0),554            )555        }556        return557    }558    Column(Modifier.fillMaxWidth()) {559        BasicTextField(560            value = draft,561            onValueChange = { draft = it },562            singleLine = true,563            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),564            cursorBrush = SolidColor(accent),565            modifier = Modifier.fillMaxWidth().drawBehind {566                drawLine(567                    color = accent,568                    start = Offset(0f, size.height),569                    end = Offset(size.width, size.height),570                    strokeWidth = 1.dp.toPx(),571                )572            },573        )574        Spacer(Modifier.height(8.dp))575        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {576            Text(577                "✓ save", color = accent, style = PeachType.labelMono,578                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {579                    onSave(draft)580                    editing = false581                }.padding(vertical = 4.dp),582            )583            Text(584                "✗", color = peachColors.T2, style = PeachType.labelMono,585                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {586                    draft = title587                    editing = false588                }.padding(horizontal = 6.dp, vertical = 4.dp),589            )590        }591    }592}593594@Composable595private fun HomographWarning() {596    Row(597        Modifier.fillMaxWidth()598            .padding(top = 8.dp)599            .clip(RoundedCornerShape(8.dp))600            .background(peachColors.BreachBg)601            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))602            .padding(horizontal = 12.dp, vertical = 9.dp),603        verticalAlignment = Alignment.CenterVertically,604    ) {605        Text("!", color = peachColors.Breach, style = PeachType.labelMono)606        Spacer(Modifier.width(10.dp))607        Text(608            "This URL contains non-ASCII or punycode characters.",609            color = peachColors.Breach,610            style = PeachType.labelMono,611            modifier = Modifier.weight(1f),612        )613    }614}615616@Composable617private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {618    val passkey = entry.passkey ?: return619    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {620        Column(Modifier.weight(1f)) {621            Text(622                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,623                color = peachColors.T0,624                style = PeachType.body,625            )626            Text(627                listOfNotNull(628                    passkey.rpId,629                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,630                ).joinToString(" · "),631                color = peachColors.T2,632                style = PeachType.labelMono,633            )634            Text(635                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",636                color = peachColors.T2,637                style = PeachType.labelMono,638                modifier = Modifier.padding(top = 3.dp),639            )640        }641    }642}643644@Composable645private fun CanaryDetailSection(646    entryId: String,647    modifiedAt: Long,648    canary: com.peachpasswords.vault.Canary?,649    isPro: Boolean,650    message: String?,651    testing: Boolean,652    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,653    onTest: (webhookUrl: String) -> Unit,654) {655    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }656    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }657    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }658    val gated = !isPro && canary == null659    FieldShell("canary alert") {660        Column(Modifier.weight(1f)) {661            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {662                Text(663                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",664                    color = peachColors.T0,665                    style = PeachType.body,666                    modifier = Modifier.weight(1f),667                )668                if (gated) {669                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)670                }671                Text(672                    if (enabled) "on" else "off",673                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,674                    style = PeachType.labelMono,675                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {676                        if (gated) {677                            onSave(true, webhookUrl, label)678                        } else {679                            val next = !enabled680                            enabled = next681                            if (!next) onSave(false, webhookUrl, label)682                        }683                    }.padding(horizontal = 6.dp, vertical = 4.dp),684                )685            }686            Text(687                "Fires a silent webhook when this entry's password or username is copied.",688                color = peachColors.T2,689                style = PeachType.labelMono,690                modifier = Modifier.padding(top = 3.dp),691            )692            if (enabled) {693                Spacer(Modifier.height(8.dp))694                PeachField(695                    label = "webhook url",696                    value = webhookUrl,697                    onChange = { webhookUrl = it },698                )699                Text(700                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",701                    color = peachColors.T2,702                    style = PeachType.labelMono,703                )704                PeachField(705                    label = "label",706                    value = label,707                    onChange = { label = it },708                    mono = false,709                )710                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {711                    Text(712                        "save",713                        color = LocalPeachAccent.current,714                        style = PeachType.labelMono,715                        modifier = Modifier.clip(RoundedCornerShape(6.dp))716                            .clickable { onSave(true, webhookUrl, label) }717                            .padding(vertical = 4.dp),718                    )719                    Text(720                        if (testing) "sending..." else "test ping",721                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,722                        style = PeachType.labelMono,723                        modifier = Modifier.clip(RoundedCornerShape(6.dp))724                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }725                            .padding(vertical = 4.dp),726                    )727                }728            }729            message?.let {730                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))731            }732            if (canary?.lastPinged != null || canary?.pingCount != null) {733                Text(734                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),735                    color = peachColors.T2,736                    style = PeachType.labelMono,737                    modifier = Modifier.padding(top = 3.dp),738                )739            }740        }741    }742}743744@Composable745private fun EntryDetailSearchStrip(746    currentIndex: Int,747    totalEntries: Int,748    onOpenList: () -> Unit,749    onPrevious: () -> Unit,750    onNext: () -> Unit,751    modifier: Modifier = Modifier,752) {753    val accent = LocalPeachAccent.current754    val hasEntries = totalEntries > 0 && currentIndex >= 0755    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"756757    Column(758        modifier759            .fillMaxWidth()760            .navigationBarsPadding()761            .padding(horizontal = 16.dp, vertical = 12.dp),762    ) {763        Row(764            Modifier765                .fillMaxWidth()766                .height(56.dp)767                .clip(RoundedCornerShape(16.dp))768                .background(peachColors.S1)769                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))770                .padding(horizontal = 12.dp),771            verticalAlignment = Alignment.CenterVertically,772        ) {773            Row(774                Modifier775                    .weight(1f)776                    .fillMaxHeight()777                    .clickable(onClick = onOpenList)778                    .pointerInput(onOpenList) {779                        var opened = false780                        detectVerticalDragGestures(781                            onDragEnd = { opened = false },782                            onDragCancel = { opened = false },783                        ) { _, dragAmount ->784                            if (!opened && dragAmount < -4f) {785                                opened = true786                                onOpenList()787                            }788                        }789                    },790                verticalAlignment = Alignment.CenterVertically,791            ) {792                Icon(793                    Icons.Rounded.Search,794                    contentDescription = null,795                    tint = accent,796                    modifier = Modifier.size(14.dp),797                )798                Spacer(Modifier.width(8.dp))799                Text(800                    "search vault...",801                    color = peachColors.T2,802                    style = PeachType.searchInput,803                    modifier = Modifier.weight(1f),804                )805                Text(806                    counter,807                    color = peachColors.T2,808                    style = PeachType.labelMono,809                )810            }811            Spacer(Modifier.width(8.dp))812            Icon(813                Icons.AutoMirrored.Rounded.ArrowBack,814                contentDescription = "Previous entry",815                tint = if (hasEntries) peachColors.T0 else peachColors.T2,816                modifier = Modifier817                    .size(38.dp)818                    .clip(CircleShape)819                    .clickable(enabled = hasEntries, onClick = onPrevious)820                    .padding(9.dp),821            )822            Icon(823                Icons.AutoMirrored.Rounded.ArrowForward,824                contentDescription = "Next entry",825                tint = if (hasEntries) peachColors.T0 else peachColors.T2,826                modifier = Modifier827                    .size(38.dp)828                    .clip(CircleShape)829                    .clickable(enabled = hasEntries, onClick = onNext)830                    .padding(9.dp),831            )832        }833    }834}835836/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */837@Composable838internal fun FieldShell(839    label: String,840    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,841) {842    val hairline = peachColors.T3843    Column(844        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {845            drawRect(846                color = hairline,847                topLeft = Offset(0f, size.height - 1.dp.toPx()),848                size = Size(size.width, 1.dp.toPx()),849            )850        },851    ) {852        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))853        Spacer(Modifier.height(6.dp))854        Row(verticalAlignment = Alignment.CenterVertically, content = content)855    }856}857858/**859 * Text for the ghost PeachScript watermark behind the entry name: the full title,860 * falling back to "P" for a blank title so the hero never renders empty.861 */862internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }863864internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =865    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)866867internal fun shortCredential(credentialId: String): String {868    if (credentialId.length <= 12) return credentialId869    return credentialId.take(6) + "..." + credentialId.takeLast(4)870}871872internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {873    val count = pingCount ?: 0874    val countLabel = "$count total alert" + if (count == 1) "" else "s"875    if (lastPinged == null) return countLabel876    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)877        .format(java.util.Date(lastPinged))878    return "Last triggered: $date · $countLabel"879}880881private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {882    val raw = url883    if (!raw.isNullOrBlank()) {884        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')885        if (cleaned.isNotEmpty()) return cleaned886    }887    return ""888}