EntryDetailScreen.kt

31.3 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.TOTPRing80import com.peachpasswords.ui.lock.Biometrics81import com.peachpasswords.ui.theme.LocalPeachAccent82import com.peachpasswords.ui.theme.LocalReduceMotion83import com.peachpasswords.ui.theme.peachColors84import com.peachpasswords.ui.theme.PeachType85import com.peachpasswords.ui.theme.ShellBackground86import com.peachpasswords.vault.TOTPGenerator87import com.peachpasswords.vault.hasTOTP88import com.peachpasswords.vault.hasPasskey89import com.peachpasswords.vault.isBreach90import com.peachpasswords.vault.isCard91import com.peachpasswords.vault.isNote92import com.peachpasswords.vault.isPasskeyOnly9394@Composable95fun EntryDetailScreen(96    id: String,97    onNavigate: (PeachNav) -> Unit,98    onBack: () -> Unit,99    vm: EntryDetailViewModel = hiltViewModel(),100) {101    val entry by vm.entry.collectAsState()102    val activeEntries by vm.activeEntries.collectAsState()103    val identitySuggestions by vm.identitySuggestions.collectAsState()104    val allTags by vm.allTags.collectAsState()105    val isPro by vm.isPro.collectAsState()106    val cvcRevealed by vm.cvcRevealed.collectAsState()107    var cardRevealed by remember { mutableStateOf(false) }108    val copiedField by vm.copiedField.collectAsState()109    val canaryMessage by vm.canaryMessage.collectAsState()110    val canaryTesting by vm.canaryTesting.collectAsState()111    val context = LocalContext.current112    val accent = LocalPeachAccent.current113    var confirmingDelete by remember { mutableStateOf(false) }114    var generatorOpen by remember { mutableStateOf(false) }115    var historyExpanded by remember { mutableStateOf(false) }116117    // FLAG_SECURE only while this screen is shown — blocks screenshots of secrets.118    DisposableEffect(Unit) {119        val window = (context as? FragmentActivity)?.window120        window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)121        onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }122    }123124    val e = entry125    ShellBackground {126        Box(Modifier.fillMaxSize().statusBarsPadding()) {127            Column(128                Modifier.fillMaxSize()129                    .padding(bottom = 100.dp)130                    .verticalScroll(rememberScrollState())131                    .padding(horizontal = 24.dp),132            ) {133                // Header: back · favicon+domain (or delete confirm) · trash134                Row(135                    Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),136                    verticalAlignment = Alignment.CenterVertically,137                ) {138                    Icon(139                        Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,140                        modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp),141                    )142                    Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {143                        when {144                            e == null -> {}145                            confirmingDelete -> Text(146                                "delete “${e.title}”?",147                                color = peachColors.Breach,148                                style = PeachType.labelMono.copy(letterSpacing = 0.08.em),149                            )150                            else -> Row(151                                verticalAlignment = Alignment.CenterVertically,152                                horizontalArrangement = Arrangement.spacedBy(8.dp),153                            ) {154                                FaviconInitials(source = e.url ?: e.title, size = 16.dp)155                                val domain = remember(e.url, e.title) { e.displayDomain() }156                                if (domain.isNotEmpty()) {157                                    Text(domain, color = peachColors.T2, style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp))158                                }159                            }160                        }161                    }162                    if (e != null) {163                        Icon(164                            Icons.Outlined.DeleteOutline, contentDescription = "delete",165                            tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,166                            modifier = Modifier.size(40.dp).clip(CircleShape)167                                .clickable { if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true }168                                .padding(8.dp),169                        )170                    }171                }172173                if (e == null) {174                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {175                        Text("entry not found", color = peachColors.T1, style = PeachType.bodyMono)176                    }177                    return@Column178                }179180                // D11 — breach banner strip when this entry's password is known-compromised.181                if (e.isBreach()) {182                    Row(183                        Modifier.fillMaxWidth()184                            .background(peachColors.BreachBg)185                            .padding(horizontal = 16.dp, vertical = 8.dp),186                        verticalAlignment = Alignment.CenterVertically,187                    ) {188                        Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)189                        Spacer(Modifier.width(10.dp))190                        Text(191                            "Password found in a data breach — change it now",192                            color = peachColors.Breach, style = PeachType.labelMono,193                            modifier = Modifier.weight(1f),194                        )195                        Text(196                            "Change password",197                            color = accent, style = PeachType.labelMono,198                            modifier = Modifier.clip(RoundedCornerShape(6.dp))199                                .clickable { generatorOpen = true }200                                .padding(horizontal = 6.dp, vertical = 4.dp),201                        )202                    }203                }204                if (entryHasHomographWarning(e)) {205                    HomographWarning()206                }207208                // Hero — ghost PeachScript watermark behind the entry name. The full209                // title is drawn (not just its first letter); a long title overflows210                // and clips naturally, which is the intended watermark behavior.211                Box(Modifier.fillMaxWidth().clipToBounds()) {212                    Text(213                        text = ghostWatermarkText(e.title),214                        style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),215                        color = peachColors.T0.copy(alpha = 0.03f),216                        maxLines = 1,217                        softWrap = false,218                        overflow = TextOverflow.Clip,219                        modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),220                    )221                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {222                        EditableTitleHero(223                            title = e.title,224                            onSave = { vm.updateField("title", it) },225                        )226                    }227                }228                Spacer(Modifier.height(20.dp))229230                if (e.isCard()) {231                    CardDetailFields(232                        entry = e,233                        cardRevealed = cardRevealed,234                        cvcRevealed = cvcRevealed,235                        copiedField = copiedField,236                        onToggleCardReveal = { cardRevealed = !cardRevealed },237                        onRevealCvc = {238                            val activity = context as? FragmentActivity239                            if (activity != null && Biometrics.isAvailable(context)) {240                                Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)241                            } else vm.revealCvc()242                        },243                        onMaskCvc = vm::maskCvc,244                        onCopy = vm::copy,245                        onSaveCard = vm::updateCard,246                        copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },247                    )248                } else {249                    if (!e.isNote()) {250                        EditableFieldRow(251                            label = "url", field = "url", value = e.url.orEmpty(),252                            copied = copiedField == "url",253                            onCopy = { vm.copy("url", e.url) },254                            onSave = { vm.updateField("url", it) },255                            placeholder = "add url",256                        )257                    }258                    e.username?.takeIf { it.isNotBlank() }?.let { v ->259                        EditableFieldRow(260                            label = "username", field = "username", value = v,261                            copied = copiedField == "username",262                            onCopy = { vm.copy("username", v) },263                            onSave = { vm.updateField("username", it) },264                            suggestions = identitySuggestions,265                        )266                    }267                    e.email?.takeIf { it.isNotBlank() }?.let { v ->268                        EditableFieldRow(269                            label = "email", field = "email", value = v,270                            copied = copiedField == "email",271                            onCopy = { vm.copy("email", v) },272                            onSave = { vm.updateField("email", it) },273                            suggestions = identitySuggestions,274                        )275                    }276                    AdditionalUrlRows(277                        urls = e.additionalUrls.orEmpty(),278                        copiedField = copiedField,279                        onCopy = { key, value -> vm.copy(key, value) },280                        onSave = { index, value -> vm.updateAdditionalUrl(index, value) },281                        onAdd = vm::addAdditionalUrl,282                    )283                    if (e.hasPasskey()) {284                        PasskeyDetailSection(e)285                    }286                    e.password?.takeIf { it.isNotBlank() }?.let { pw ->287                        EditablePasswordRow(288                            value = pw,289                            copied = copiedField == "password",290                            entryTitle = e.title,291                            onCopy = { vm.copy("password", pw) },292                            onSave = { vm.updateField("password", it) },293                            onGenerate = { generatorOpen = true },294                        )295                    }296                    val history = e.passwordHistory.orEmpty()297                    if (history.isNotEmpty()) {298                        PasswordHistoryToggle(299                            count = history.size,300                            expanded = historyExpanded,301                            onToggle = { historyExpanded = !historyExpanded },302                        )303                        AnimatedVisibility(visible = historyExpanded) {304                            Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {305                                PasswordHistoryList(306                                    history = history,307                                    onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },308                                    onCopy = { oldPw -> vm.copy("password", oldPw) },309                                )310                            }311                        }312                    }313                    if (e.hasTOTP()) {314                        Spacer(Modifier.height(4.dp))315                        TotpBandEditable(316                            totpField = e.totp!!,317                            copied = copiedField == "totp",318                            onCopy = vm::copyTotp,319                            onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },320                        )321                    }322                }323                e.notes?.takeIf { it.isNotBlank() }?.let { v ->324                    EditableFieldRow(325                        label = "notes", field = "notes", value = v,326                        copied = copiedField == "notes",327                        onCopy = { vm.copy("notes", v) },328                        onSave = { vm.updateField("notes", it) },329                        mono = false, singleLine = false,330                    )331                }332                DetailTagRow(333                    tags = e.tags.orEmpty(),334                    allTags = allTags,335                    onAddTag = vm::addTag,336                    onRemoveTag = vm::removeTag,337                )338                if (!e.isNote() && !e.isCard()) {339                    CanaryDetailSection(340                        entryId = e.id,341                        modifiedAt = e.modifiedAt,342                        canary = e.canary,343                        isPro = isPro,344                        message = canaryMessage,345                        testing = canaryTesting,346                        onSave = vm::saveCanary,347                        onTest = vm::testCanary,348                    )349                }350351                Spacer(Modifier.height(24.dp))352            }353354            if (e != null) {355                EntryDetailSearchStrip(356                    currentIndex = activeEntries.indexOfFirst { it.id == e.id },357                    totalEntries = activeEntries.size,358                    onOpenList = { onNavigate(PeachNav.VaultList) },359                    onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },360                    onNext = { vm.adjacentDestination(1)?.let(onNavigate) },361                    modifier = Modifier.align(Alignment.BottomCenter),362                )363            }364365            if (generatorOpen) {366                GeneratorSheet(367                    copied = copiedField == "genDraft",368                    onCopy = { draft -> vm.copy("genDraft", draft) },369                    onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },370                    onDismiss = { generatorOpen = false },371                )372            }373        }374    }375}376377@Composable378private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {379    val accent = LocalPeachAccent.current380    var editing by remember(title) { mutableStateOf(false) }381    var draft by remember(title) { mutableStateOf(title) }382    if (!editing) {383        Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {384            CharEnterText(385                text = title,386                style = PeachType.entryNameHero.copy(color = peachColors.T0),387            )388        }389        return390    }391    Column(Modifier.fillMaxWidth()) {392        BasicTextField(393            value = draft,394            onValueChange = { draft = it },395            singleLine = true,396            textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),397            cursorBrush = SolidColor(accent),398            modifier = Modifier.fillMaxWidth().drawBehind {399                drawLine(400                    color = accent,401                    start = Offset(0f, size.height),402                    end = Offset(size.width, size.height),403                    strokeWidth = 1.dp.toPx(),404                )405            },406        )407        Spacer(Modifier.height(8.dp))408        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {409            Text(410                "✓ save", color = accent, style = PeachType.labelMono,411                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {412                    onSave(draft)413                    editing = false414                }.padding(vertical = 4.dp),415            )416            Text(417                "✗", color = peachColors.T2, style = PeachType.labelMono,418                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {419                    draft = title420                    editing = false421                }.padding(horizontal = 6.dp, vertical = 4.dp),422            )423        }424    }425}426427@Composable428private fun HomographWarning() {429    Row(430        Modifier.fillMaxWidth()431            .padding(top = 8.dp)432            .clip(RoundedCornerShape(8.dp))433            .background(peachColors.BreachBg)434            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))435            .padding(horizontal = 12.dp, vertical = 9.dp),436        verticalAlignment = Alignment.CenterVertically,437    ) {438        Text("!", color = peachColors.Breach, style = PeachType.labelMono)439        Spacer(Modifier.width(10.dp))440        Text(441            "This URL contains non-ASCII or punycode characters.",442            color = peachColors.Breach,443            style = PeachType.labelMono,444            modifier = Modifier.weight(1f),445        )446    }447}448449@Composable450private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {451    val passkey = entry.passkey ?: return452    FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {453        Column(Modifier.weight(1f)) {454            Text(455                passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,456                color = peachColors.T0,457                style = PeachType.body,458            )459            Text(460                listOfNotNull(461                    passkey.rpId,462                    passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,463                ).joinToString(" · "),464                color = peachColors.T2,465                style = PeachType.labelMono,466            )467            Text(468                "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",469                color = peachColors.T2,470                style = PeachType.labelMono,471                modifier = Modifier.padding(top = 3.dp),472            )473        }474    }475}476477@Composable478private fun CanaryDetailSection(479    entryId: String,480    modifiedAt: Long,481    canary: com.peachpasswords.vault.Canary?,482    isPro: Boolean,483    message: String?,484    testing: Boolean,485    onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,486    onTest: (webhookUrl: String) -> Unit,487) {488    var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }489    var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }490    var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }491    val gated = !isPro && canary == null492    FieldShell("canary alert") {493        Column(Modifier.weight(1f)) {494            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {495                Text(496                    label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",497                    color = peachColors.T0,498                    style = PeachType.body,499                    modifier = Modifier.weight(1f),500                )501                if (gated) {502                    Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)503                }504                Text(505                    if (enabled) "on" else "off",506                    color = if (enabled) LocalPeachAccent.current else peachColors.T2,507                    style = PeachType.labelMono,508                    modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {509                        if (gated) {510                            onSave(true, webhookUrl, label)511                        } else {512                            val next = !enabled513                            enabled = next514                            if (!next) onSave(false, webhookUrl, label)515                        }516                    }.padding(horizontal = 6.dp, vertical = 4.dp),517                )518            }519            Text(520                "Fires a silent webhook when this entry's password or username is copied.",521                color = peachColors.T2,522                style = PeachType.labelMono,523                modifier = Modifier.padding(top = 3.dp),524            )525            if (enabled) {526                Spacer(Modifier.height(8.dp))527                PeachField(528                    label = "webhook url",529                    value = webhookUrl,530                    onChange = { webhookUrl = it },531                )532                Text(533                    "When triggered, the entry title is sent to this URL. Use an endpoint you control.",534                    color = peachColors.T2,535                    style = PeachType.labelMono,536                )537                PeachField(538                    label = "label",539                    value = label,540                    onChange = { label = it },541                    mono = false,542                )543                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {544                    Text(545                        "save",546                        color = LocalPeachAccent.current,547                        style = PeachType.labelMono,548                        modifier = Modifier.clip(RoundedCornerShape(6.dp))549                            .clickable { onSave(true, webhookUrl, label) }550                            .padding(vertical = 4.dp),551                    )552                    Text(553                        if (testing) "sending..." else "test ping",554                        color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,555                        style = PeachType.labelMono,556                        modifier = Modifier.clip(RoundedCornerShape(6.dp))557                            .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }558                            .padding(vertical = 4.dp),559                    )560                }561            }562            message?.let {563                Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))564            }565            if (canary?.lastPinged != null || canary?.pingCount != null) {566                Text(567                    canaryStatusLabel(canary?.lastPinged, canary?.pingCount),568                    color = peachColors.T2,569                    style = PeachType.labelMono,570                    modifier = Modifier.padding(top = 3.dp),571                )572            }573        }574    }575}576577@Composable578private fun EntryDetailSearchStrip(579    currentIndex: Int,580    totalEntries: Int,581    onOpenList: () -> Unit,582    onPrevious: () -> Unit,583    onNext: () -> Unit,584    modifier: Modifier = Modifier,585) {586    val accent = LocalPeachAccent.current587    val hasEntries = totalEntries > 0 && currentIndex >= 0588    val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"589590    Column(591        modifier592            .fillMaxWidth()593            .navigationBarsPadding()594            .padding(horizontal = 16.dp, vertical = 12.dp),595    ) {596        Row(597            Modifier598                .fillMaxWidth()599                .height(56.dp)600                .clip(RoundedCornerShape(16.dp))601                .background(peachColors.S1)602                .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))603                .padding(horizontal = 12.dp),604            verticalAlignment = Alignment.CenterVertically,605        ) {606            Row(607                Modifier608                    .weight(1f)609                    .fillMaxHeight()610                    .clickable(onClick = onOpenList)611                    .pointerInput(onOpenList) {612                        var opened = false613                        detectVerticalDragGestures(614                            onDragEnd = { opened = false },615                            onDragCancel = { opened = false },616                        ) { _, dragAmount ->617                            if (!opened && dragAmount < -4f) {618                                opened = true619                                onOpenList()620                            }621                        }622                    },623                verticalAlignment = Alignment.CenterVertically,624            ) {625                Icon(626                    Icons.Rounded.Search,627                    contentDescription = null,628                    tint = accent,629                    modifier = Modifier.size(14.dp),630                )631                Spacer(Modifier.width(8.dp))632                Text(633                    "search vault...",634                    color = peachColors.T2,635                    style = PeachType.searchInput,636                    modifier = Modifier.weight(1f),637                )638                Text(639                    counter,640                    color = peachColors.T2,641                    style = PeachType.labelMono,642                )643            }644            Spacer(Modifier.width(8.dp))645            Icon(646                Icons.AutoMirrored.Rounded.ArrowBack,647                contentDescription = "Previous entry",648                tint = if (hasEntries) peachColors.T0 else peachColors.T2,649                modifier = Modifier650                    .size(38.dp)651                    .clip(CircleShape)652                    .clickable(enabled = hasEntries, onClick = onPrevious)653                    .padding(9.dp),654            )655            Icon(656                Icons.AutoMirrored.Rounded.ArrowForward,657                contentDescription = "Next entry",658                tint = if (hasEntries) peachColors.T0 else peachColors.T2,659                modifier = Modifier660                    .size(38.dp)661                    .clip(CircleShape)662                    .clickable(enabled = hasEntries, onClick = onNext)663                    .padding(9.dp),664            )665        }666    }667}668669/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */670@Composable671internal fun FieldShell(672    label: String,673    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,674) {675    val hairline = peachColors.T3676    Column(677        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {678            drawRect(679                color = hairline,680                topLeft = Offset(0f, size.height - 1.dp.toPx()),681                size = Size(size.width, 1.dp.toPx()),682            )683        },684    ) {685        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))686        Spacer(Modifier.height(6.dp))687        Row(verticalAlignment = Alignment.CenterVertically, content = content)688    }689}690691/**692 * Text for the ghost PeachScript watermark behind the entry name: the full title,693 * falling back to "P" for a blank title so the hero never renders empty.694 */695internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }696697internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =698    (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)699700internal fun shortCredential(credentialId: String): String {701    if (credentialId.length <= 12) return credentialId702    return credentialId.take(6) + "..." + credentialId.takeLast(4)703}704705internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {706    val count = pingCount ?: 0707    val countLabel = "$count total alert" + if (count == 1) "" else "s"708    if (lastPinged == null) return countLabel709    val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)710        .format(java.util.Date(lastPinged))711    return "Last triggered: $date · $countLabel"712}713714private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {715    val raw = url716    if (!raw.isNullOrBlank()) {717        val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')718        if (cleaned.isNotEmpty()) return cleaned719    }720    return ""721}