EntryDetailScreen.kt

31.5 KB · raw

1package com.peachpasswords.ui.vault23import android.view.WindowManager4import androidx.compose.animation.AnimatedVisibility5import androidx.compose.animation.core.LinearEasing6import androidx.compose.animation.core.RepeatMode7import androidx.compose.animation.core.animateFloat8import androidx.compose.animation.core.animateFloatAsState9import androidx.compose.animation.core.infiniteRepeatable10import androidx.compose.animation.core.rememberInfiniteTransition11import androidx.compose.animation.core.tween12import androidx.compose.foundation.background13import androidx.compose.foundation.border14import androidx.compose.foundation.clickable15import 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.material3.Icon44import androidx.compose.material3.Text45import androidx.compose.runtime.Composable46import androidx.compose.runtime.DisposableEffect47import 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.TOTPRing82import com.peachpasswords.ui.lock.Biometrics83import com.peachpasswords.ui.theme.LocalPeachAccent84import com.peachpasswords.ui.theme.LocalReduceMotion85import com.peachpasswords.ui.theme.PeachSpacing86import com.peachpasswords.ui.theme.peachColors87import com.peachpasswords.ui.theme.PeachType88import com.peachpasswords.ui.theme.ShellBackground89import com.peachpasswords.vault.TOTPGenerator90import com.peachpasswords.vault.hasTOTP91import com.peachpasswords.vault.hasPasskey92import com.peachpasswords.vault.isBreach93import com.peachpasswords.vault.isCard94import com.peachpasswords.vault.isNote95import com.peachpasswords.vault.isPasskeyOnly9697@Composable98fun EntryDetailScreen(99    id: String,100    onNavigate: (PeachNav) -> Unit,101    onBack: () -> Unit,102    vm: EntryDetailViewModel = hiltViewModel(),103) {104    val entry by vm.entry.collectAsState()105    val activeEntries by vm.activeEntries.collectAsState()106    val identitySuggestions by vm.identitySuggestions.collectAsState()107    val allTags by vm.allTags.collectAsState()108    val isPro by vm.isPro.collectAsState()109    val cvcRevealed by vm.cvcRevealed.collectAsState()110    var cardRevealed by remember { mutableStateOf(false) }111    val copiedField by vm.copiedField.collectAsState()112    val canaryMessage by vm.canaryMessage.collectAsState()113    val canaryTesting by vm.canaryTesting.collectAsState()114    val context = LocalContext.current115    val accent = LocalPeachAccent.current116    var confirmingDelete by remember { mutableStateOf(false) }117    var generatorOpen by remember { mutableStateOf(false) }118    var historyExpanded by remember { mutableStateOf(false) }119120    // FLAG_SECURE only while this screen is shown — blocks screenshots of secrets.121    DisposableEffect(Unit) {122        val window = (context as? FragmentActivity)?.window123        window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)124        onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }125    }126127    val e = entry128    ShellBackground {129        Box(Modifier.fillMaxSize().statusBarsPadding()) {130            Column(131                Modifier.fillMaxSize()132                    .padding(bottom = 100.dp)133                    .verticalScroll(rememberScrollState())134                    .padding(horizontal = PeachSpacing.xl),135            ) {136                EntryDetailTopBar(137                    entry = e,138                    confirmingDelete = confirmingDelete,139                    onBack = onBack,140                    onDelete = {141                        if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true142                    },143                )144                Spacer(Modifier.height(PeachSpacing.lg))145146                if (e == null) {147                    PeachEmptyOrEndState(148                        message = "entry not found",149                        detail = "It may have been moved or deleted.",150                        modifier = Modifier.padding(top = PeachSpacing.xxl),151                    )152                    return@Column153                }154155                // D11 — breach banner strip when this entry's password is known-compromised.156                if (e.isBreach()) {157                    Row(158                        Modifier.fillMaxWidth()159                            .background(peachColors.BreachBg)160                            .padding(horizontal = 16.dp, vertical = 8.dp),161                        verticalAlignment = Alignment.CenterVertically,162                    ) {163                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)164                        Spacer(Modifier.width(10.dp))165                        Text(166                            "Password found in a data breach — change it now",167                            color = peachColors.Breach, style = PeachType.labelMono,168                            modifier = Modifier.weight(1f),169                        )170                        Text(171                            "Change password",172                            color = accent, style = PeachType.labelMono,173                            modifier = Modifier.clip(RoundedCornerShape(6.dp))174                                .clickable { generatorOpen = true }175                                .padding(horizontal = 6.dp, vertical = 4.dp),176                        )177                    }178                }179                if (entryHasHomographWarning(e)) {180                    HomographWarning()181                }182183                // Hero — ghost PeachScript watermark behind the entry name. The full184                // title is drawn (not just its first letter); a long title overflows185                // and clips naturally, which is the intended watermark behavior.186                Box(Modifier.fillMaxWidth().clipToBounds()) {187                    Text(188                        text = ghostWatermarkText(e.title),189                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),190                        color = peachColors.T0.copy(alpha = 0.03f),191                        maxLines = 1,192                        softWrap = false,193                        overflow = TextOverflow.Clip,194                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),195                    )196                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {197                        EditableTitleHero(198                            title = e.title,199                            onSave = { vm.updateField("title", it) },200                        )201                    }202                }203                Spacer(Modifier.height(PeachSpacing.lg))204205                if (e.isCard()) {206                    CardDetailFields(207                        entry = e,208                        cardRevealed = cardRevealed,209                        cvcRevealed = cvcRevealed,210                        copiedField = copiedField,211                        onToggleCardReveal = { cardRevealed = !cardRevealed },212                        onRevealCvc = {213                            val activity = context as? FragmentActivity214                            if (activity != null && Biometrics.isAvailable(context)) {215                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)216                            } else vm.revealCvc()217                        },218                        onMaskCvc = vm::maskCvc,219                        onCopy = vm::copy,220                        onSaveCard = vm::updateCard,221                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },222                    )223                } else {224                    if (!e.isNote()) {225                        EditableFieldRow(226                            label = "url", field = "url", value = e.url.orEmpty(),227                            copied = copiedField == "url",228                            onCopy = { vm.copy("url", e.url) },229                            onSave = { vm.updateField("url", it) },230                            placeholder = "add url",231                        )232                    }233                    e.username?.takeIf { it.isNotBlank() }?.let { v ->234                        EditableFieldRow(235                            label = "username", field = "username", value = v,236                            copied = copiedField == "username",237                            onCopy = { vm.copy("username", v) },238                            onSave = { vm.updateField("username", it) },239                            suggestions = identitySuggestions,240                        )241                    }242                    e.email?.takeIf { it.isNotBlank() }?.let { v ->243                        EditableFieldRow(244                            label = "email", field = "email", value = v,245                            copied = copiedField == "email",246                            onCopy = { vm.copy("email", v) },247                            onSave = { vm.updateField("email", it) },248                            suggestions = identitySuggestions,249                        )250                    }251                    AdditionalUrlRows(252                        urls = e.additionalUrls.orEmpty(),253                        copiedField = copiedField,254                        onCopy = { key, value -> vm.copy(key, value) },255                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },256                        onAdd = vm::addAdditionalUrl,257                    )258                    if (e.hasPasskey()) {259                        PasskeyDetailSection(e)260                    }261                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->262                        EditablePasswordRow(263                            value = pw,264                            copied = copiedField == "password",265                            entryTitle = e.title,266                            onCopy = { vm.copy("password", pw) },267                            onSave = { vm.updateField("password", it) },268                            onGenerate = { generatorOpen = true },269                        )270                    }271                    val history = e.passwordHistory.orEmpty()272                    if (history.isNotEmpty()) {273                        PasswordHistoryToggle(274                            count = history.size,275                            expanded = historyExpanded,276                            onToggle = { historyExpanded = !historyExpanded },277                        )278                        AnimatedVisibility(visible = historyExpanded) {279                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {280                                PasswordHistoryList(281                                    history = history,282                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },283                                    onCopy = { oldPw -> vm.copy("password", oldPw) },284                                )285                            }286                        }287                    }288                    if (e.hasTOTP()) {289                        Spacer(Modifier.height(4.dp))290                        TotpBandEditable(291                            totpField = e.totp!!,292                            copied = copiedField == "totp",293                            onCopy = vm::copyTotp,294                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },295                        )296                    }297                }298                e.notes?.takeIf { it.isNotBlank() }?.let { v ->299                    EditableFieldRow(300                        label = "notes", field = "notes", value = v,301                        copied = copiedField == "notes",302                        onCopy = { vm.copy("notes", v) },303                        onSave = { vm.updateField("notes", it) },304                        mono = false, singleLine = false,305                    )306                }307                DetailTagRow(308                    tags = e.tags.orEmpty(),309                    allTags = allTags,310                    onAddTag = vm::addTag,311                    onRemoveTag = vm::removeTag,312                )313                if (!e.isNote() && !e.isCard()) {314                    CanaryDetailSection(315                        entryId = e.id,316                        modifiedAt = e.modifiedAt,317                        canary = e.canary,318                        isPro = isPro,319                        message = canaryMessage,320                        testing = canaryTesting,321                        onSave = vm::saveCanary,322                        onTest = vm::testCanary,323                    )324                }325326                PeachEmptyOrEndState(327                    message = "end of entry",328                    detail = "All saved fields for this item are above.",329                    modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),330                )331            }332333            if (e != null) {334                EntryDetailSearchStrip(335                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },336                    totalEntries = activeEntries.size,337                    onOpenList = { onNavigate(PeachNav.VaultList) },338                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },339                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },340                    modifier = Modifier.align(Alignment.BottomCenter),341                )342            }343344            if (generatorOpen) {345                GeneratorSheet(346                    copied = copiedField == "genDraft",347                    onCopy = { draft -> vm.copy("genDraft", draft) },348                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },349                    onDismiss = { generatorOpen = false },350                )351            }352        }353    }354}355356@Composable357private fun EntryDetailTopBar(358    entry: com.peachpasswords.vault.VaultEntry?,359    confirmingDelete: Boolean,360    onBack: () -> Unit,361    onDelete: () -> Unit,362) {363    val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }364    PeachTopBar(365        title = when {366            entry == null -> "entry"367            confirmingDelete -> "delete \"${entry.title}\"?"368            domain.isNotEmpty() -> domain369            else -> entry.title370        },371        leading = {372            Icon(373                Icons.AutoMirrored.Rounded.ArrowBack,374                contentDescription = "back",375                tint = peachColors.T0,376                modifier = Modifier377                    .size(40.dp)378                    .clip(CircleShape)379                    .clickable(onClick = onBack)380                    .padding(PeachSpacing.sm),381            )382            if (entry != null && !confirmingDelete) {383                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)384            }385        },386        actions = {387            if (entry != null) {388                Icon(389                    Icons.Outlined.DeleteOutline,390                    contentDescription = "delete",391                    tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,392                    modifier = Modifier393                        .size(40.dp)394                        .clip(CircleShape)395                        .clickable(onClick = onDelete)396                        .padding(PeachSpacing.sm),397                )398            }399        },400    )401}402403@Composable404private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {405    val accent = LocalPeachAccent.current406    var editing by remember(title) { mutableStateOf(false) }407    var draft by remember(title) { mutableStateOf(title) }408    if (!editing) {409        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {410            CharEnterText(411                text = title,412                style = PeachType.entryNameHero.copy(color = peachColors.T0),413            )414        }415        return416    }417    Column(Modifier.fillMaxWidth()) {418        BasicTextField(419            value = draft,420            onValueChange = { draft = it },421            singleLine = true,422            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),423            cursorBrush = SolidColor(accent),424            modifier = Modifier.fillMaxWidth().drawBehind {425                drawLine(426                    color = accent,427                    start = Offset(0f, size.height),428                    end = Offset(size.width, size.height),429                    strokeWidth = 1.dp.toPx(),430                )431            },432        )433        Spacer(Modifier.height(8.dp))434        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {435            Text(436                "✓ save", color = accent, style = PeachType.labelMono,437                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {438                    onSave(draft)439                    editing = false440                }.padding(vertical = 4.dp),441            )442            Text(443                "✗", color = peachColors.T2, style = PeachType.labelMono,444                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {445                    draft = title446                    editing = false447                }.padding(horizontal = 6.dp, vertical = 4.dp),448            )449        }450    }451}452453@Composable454private fun HomographWarning() {455    Row(456        Modifier.fillMaxWidth()457            .padding(top = 8.dp)458            .clip(RoundedCornerShape(8.dp))459            .background(peachColors.BreachBg)460            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))461            .padding(horizontal = 12.dp, vertical = 9.dp),462        verticalAlignment = Alignment.CenterVertically,463    ) {464        Text("!", color = peachColors.Breach, style = PeachType.labelMono)465        Spacer(Modifier.width(10.dp))466        Text(467            "This URL contains non-ASCII or punycode characters.",468            color = peachColors.Breach,469            style = PeachType.labelMono,470            modifier = Modifier.weight(1f),471        )472    }473}474475@Composable476private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {477    val passkey = entry.passkey ?: return478    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {479        Column(Modifier.weight(1f)) {480            Text(481                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,482                color = peachColors.T0,483                style = PeachType.body,484            )485            Text(486                listOfNotNull(487                    passkey.rpId,488                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,489                ).joinToString(" · "),490                color = peachColors.T2,491                style = PeachType.labelMono,492            )493            Text(494                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",495                color = peachColors.T2,496                style = PeachType.labelMono,497                modifier = Modifier.padding(top = 3.dp),498            )499        }500    }501}502503@Composable504private fun CanaryDetailSection(505    entryId: String,506    modifiedAt: Long,507    canary: com.peachpasswords.vault.Canary?,508    isPro: Boolean,509    message: String?,510    testing: Boolean,511    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,512    onTest: (webhookUrl: String) -> Unit,513) {514    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }515    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }516    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }517    val gated = !isPro && canary == null518    FieldShell("canary alert") {519        Column(Modifier.weight(1f)) {520            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {521                Text(522                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",523                    color = peachColors.T0,524                    style = PeachType.body,525                    modifier = Modifier.weight(1f),526                )527                if (gated) {528                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)529                }530                Text(531                    if (enabled) "on" else "off",532                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,533                    style = PeachType.labelMono,534                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {535                        if (gated) {536                            onSave(true, webhookUrl, label)537                        } else {538                            val next = !enabled539                            enabled = next540                            if (!next) onSave(false, webhookUrl, label)541                        }542                    }.padding(horizontal = 6.dp, vertical = 4.dp),543                )544            }545            Text(546                "Fires a silent webhook when this entry's password or username is copied.",547                color = peachColors.T2,548                style = PeachType.labelMono,549                modifier = Modifier.padding(top = 3.dp),550            )551            if (enabled) {552                Spacer(Modifier.height(8.dp))553                PeachField(554                    label = "webhook url",555                    value = webhookUrl,556                    onChange = { webhookUrl = it },557                )558                Text(559                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",560                    color = peachColors.T2,561                    style = PeachType.labelMono,562                )563                PeachField(564                    label = "label",565                    value = label,566                    onChange = { label = it },567                    mono = false,568                )569                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {570                    Text(571                        "save",572                        color = LocalPeachAccent.current,573                        style = PeachType.labelMono,574                        modifier = Modifier.clip(RoundedCornerShape(6.dp))575                            .clickable { onSave(true, webhookUrl, label) }576                            .padding(vertical = 4.dp),577                    )578                    Text(579                        if (testing) "sending..." else "test ping",580                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,581                        style = PeachType.labelMono,582                        modifier = Modifier.clip(RoundedCornerShape(6.dp))583                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }584                            .padding(vertical = 4.dp),585                    )586                }587            }588            message?.let {589                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))590            }591            if (canary?.lastPinged != null || canary?.pingCount != null) {592                Text(593                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),594                    color = peachColors.T2,595                    style = PeachType.labelMono,596                    modifier = Modifier.padding(top = 3.dp),597                )598            }599        }600    }601}602603@Composable604private fun EntryDetailSearchStrip(605    currentIndex: Int,606    totalEntries: Int,607    onOpenList: () -> Unit,608    onPrevious: () -> Unit,609    onNext: () -> Unit,610    modifier: Modifier = Modifier,611) {612    val accent = LocalPeachAccent.current613    val hasEntries = totalEntries > 0 && currentIndex >= 0614    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"615616    Column(617        modifier618            .fillMaxWidth()619            .navigationBarsPadding()620            .padding(horizontal = 16.dp, vertical = 12.dp),621    ) {622        Row(623            Modifier624                .fillMaxWidth()625                .height(56.dp)626                .clip(RoundedCornerShape(16.dp))627                .background(peachColors.S1)628                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))629                .padding(horizontal = 12.dp),630            verticalAlignment = Alignment.CenterVertically,631        ) {632            Row(633                Modifier634                    .weight(1f)635                    .fillMaxHeight()636                    .clickable(onClick = onOpenList)637                    .pointerInput(onOpenList) {638                        var opened = false639                        detectVerticalDragGestures(640                            onDragEnd = { opened = false },641                            onDragCancel = { opened = false },642                        ) { _, dragAmount ->643                            if (!opened && dragAmount < -4f) {644                                opened = true645                                onOpenList()646                            }647                        }648                    },649                verticalAlignment = Alignment.CenterVertically,650            ) {651                Icon(652                    Icons.Rounded.Search,653                    contentDescription = null,654                    tint = accent,655                    modifier = Modifier.size(14.dp),656                )657                Spacer(Modifier.width(8.dp))658                Text(659                    "search vault...",660                    color = peachColors.T2,661                    style = PeachType.searchInput,662                    modifier = Modifier.weight(1f),663                )664                Text(665                    counter,666                    color = peachColors.T2,667                    style = PeachType.labelMono,668                )669            }670            Spacer(Modifier.width(8.dp))671            Icon(672                Icons.AutoMirrored.Rounded.ArrowBack,673                contentDescription = "Previous entry",674                tint = if (hasEntries) peachColors.T0 else peachColors.T2,675                modifier = Modifier676                    .size(38.dp)677                    .clip(CircleShape)678                    .clickable(enabled = hasEntries, onClick = onPrevious)679                    .padding(9.dp),680            )681            Icon(682                Icons.AutoMirrored.Rounded.ArrowForward,683                contentDescription = "Next entry",684                tint = if (hasEntries) peachColors.T0 else peachColors.T2,685                modifier = Modifier686                    .size(38.dp)687                    .clip(CircleShape)688                    .clickable(enabled = hasEntries, onClick = onNext)689                    .padding(9.dp),690            )691        }692    }693}694695/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */696@Composable697internal fun FieldShell(698    label: String,699    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,700) {701    val hairline = peachColors.T3702    Column(703        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {704            drawRect(705                color = hairline,706                topLeft = Offset(0f, size.height - 1.dp.toPx()),707                size = Size(size.width, 1.dp.toPx()),708            )709        },710    ) {711        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))712        Spacer(Modifier.height(6.dp))713        Row(verticalAlignment = Alignment.CenterVertically, content = content)714    }715}716717/**718 * Text for the ghost PeachScript watermark behind the entry name: the full title,719 * falling back to "P" for a blank title so the hero never renders empty.720 */721internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }722723internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =724    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)725726internal fun shortCredential(credentialId: String): String {727    if (credentialId.length <= 12) return credentialId728    return credentialId.take(6) + "..." + credentialId.takeLast(4)729}730731internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {732    val count = pingCount ?: 0733    val countLabel = "$count total alert" + if (count == 1) "" else "s"734    if (lastPinged == null) return countLabel735    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)736        .format(java.util.Date(lastPinged))737    return "Last triggered: $date · $countLabel"738}739740private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {741    val raw = url742    if (!raw.isNullOrBlank()) {743        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')744        if (cleaned.isNotEmpty()) return cleaned745    }746    return ""747}