1package com.peachpasswords.ui.vault23import androidx.compose.animation.AnimatedVisibility4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectHorizontalDragGestures15import androidx.compose.foundation.gestures.detectVerticalDragGestures16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.Column19import androidx.compose.foundation.layout.Row20import androidx.compose.foundation.layout.Spacer21import androidx.compose.foundation.layout.fillMaxSize22import androidx.compose.foundation.layout.fillMaxHeight23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.offset26import androidx.compose.foundation.layout.padding27import androidx.compose.foundation.layout.navigationBarsPadding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.layout.width31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.shape.CircleShape33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material.icons.rounded.Add36import androidx.compose.material.icons.rounded.Check37import androidx.compose.material.icons.rounded.ContentCopy38import androidx.compose.material.icons.rounded.Search39import androidx.compose.material.icons.rounded.Settings40import androidx.compose.foundation.verticalScroll41import androidx.compose.material.icons.Icons42import androidx.compose.material.icons.automirrored.rounded.ArrowBack43import androidx.compose.material.icons.automirrored.rounded.ArrowForward44import androidx.compose.material.icons.outlined.Add45import androidx.compose.material.icons.outlined.Delete46import androidx.compose.material.icons.outlined.Description47import androidx.compose.material.icons.outlined.Settings48import androidx.compose.material.icons.outlined.Lock49import androidx.compose.material3.Icon50import androidx.compose.material3.Text51import androidx.compose.runtime.Composable52import androidx.compose.runtime.getValue53import androidx.compose.runtime.mutableStateOf54import androidx.compose.runtime.produceState55import androidx.compose.runtime.remember56import androidx.compose.runtime.setValue57import androidx.compose.ui.Alignment58import androidx.compose.ui.Modifier59import androidx.compose.ui.draw.clip60import androidx.compose.ui.draw.clipToBounds61import androidx.compose.ui.draw.drawBehind62import androidx.compose.ui.geometry.Offset63import androidx.compose.ui.geometry.Size64import androidx.compose.ui.graphics.Brush65import androidx.compose.ui.graphics.Color66import androidx.compose.ui.graphics.SolidColor67import androidx.compose.ui.graphics.graphicsLayer68import androidx.compose.ui.platform.LocalContext69import androidx.compose.ui.input.pointer.pointerInput70import androidx.compose.ui.text.TextStyle71import androidx.compose.ui.text.font.FontStyle72import androidx.compose.ui.text.style.TextOverflow73import androidx.compose.ui.unit.dp74import androidx.compose.ui.unit.em75import androidx.compose.ui.unit.sp76import androidx.compose.runtime.collectAsState77import androidx.fragment.app.FragmentActivity78import androidx.hilt.navigation.compose.hiltViewModel79import com.peachpasswords.shared.detectHomograph80import com.peachpasswords.ui.PeachNav81import com.peachpasswords.ui.components.CharEnterText82import com.peachpasswords.ui.components.CopyButton83import com.peachpasswords.ui.components.PeachEmptyOrEndState84import com.peachpasswords.ui.components.PeachMark85import com.peachpasswords.ui.components.PeachTopBar86import com.peachpasswords.ui.components.TOTPRing87import com.peachpasswords.ui.lock.Biometrics88import com.peachpasswords.ui.theme.LocalPeachAccent89import com.peachpasswords.ui.theme.LocalReduceMotion90import com.peachpasswords.ui.theme.PeachSpacing91import com.peachpasswords.ui.theme.peachColors92import com.peachpasswords.ui.theme.PeachType93import com.peachpasswords.ui.theme.ShellBackground94import com.peachpasswords.vault.TOTPGenerator95import com.peachpasswords.vault.hasTOTP96import com.peachpasswords.vault.hasPasskey97import com.peachpasswords.vault.isBreach98import com.peachpasswords.vault.isCard99import com.peachpasswords.vault.isNote100import com.peachpasswords.vault.isPasskeyOnly101102@Composable103fun EntryDetailScreen(104 id: String,105 onNavigate: (PeachNav) -> Unit,106 107 108 109 onBack: (() -> Unit)?,110 modifier: Modifier = Modifier,111 vm: EntryDetailViewModel = hiltViewModel(),112) {113 val entry by vm.entry.collectAsState()114 val activeEntries by vm.activeEntries.collectAsState()115 val identitySuggestions by vm.identitySuggestions.collectAsState()116 val allTags by vm.allTags.collectAsState()117 val isPro by vm.isPro.collectAsState()118 val cvcRevealed by vm.cvcRevealed.collectAsState()119 var cardRevealed by remember { mutableStateOf(false) }120 val copiedField by vm.copiedField.collectAsState()121 val canaryMessage by vm.canaryMessage.collectAsState()122 val canaryTesting by vm.canaryTesting.collectAsState()123 val context = LocalContext.current124 val accent = LocalPeachAccent.current125 var confirmingDelete by remember { mutableStateOf(false) }126 var generatorOpen by remember { mutableStateOf(false) }127 var historyExpanded by remember { mutableStateOf(false) }128129 val e = entry130 ShellBackground(modifier) {131 Box(132 Modifier.fillMaxSize().statusBarsPadding()133 134 135 136 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.Notes) },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 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 214 215 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 264 265 266 267 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 361 362 val notesAreHtml = isNoteHtml(v)363 EditableFieldRow(364 label = "notes", field = "notes", value = v,365 copied = copiedField == "notes",366 onCopy = { vm.copy("notes", notePlainText(v)) },367 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },368 mono = false, singleLine = false,369 displayText = noteRendered(v),370 draftText = noteEditorText(v),371 )372 }373 DetailTagRow(374 tags = e.tags.orEmpty(),375 allTags = allTags,376 onAddTag = vm::addTag,377 onRemoveTag = vm::removeTag,378 )379 if (!e.isNote() && !e.isCard()) {380 381 382 383 384 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }385 if (advancedExpanded) {386 CanaryDetailSection(387 entryId = e.id,388 modifiedAt = e.modifiedAt,389 canary = e.canary,390 isPro = isPro,391 message = canaryMessage,392 testing = canaryTesting,393 onSave = vm::saveCanary,394 onTest = vm::testCanary,395 )396 } else {397 Text(398 "+ advanced",399 color = LocalPeachAccent.current,400 style = PeachType.labelMono,401 modifier = Modifier402 .clip(RoundedCornerShape(6.dp))403 .clickable { advancedExpanded = true }404 .padding(vertical = 6.dp),405 )406 }407 }408409 Spacer(Modifier.height(PeachSpacing.xxl))410 }411412 if (e != null) {413 EntryDetailSearchStrip(414 currentIndex = activeEntries.indexOfFirst { it.id == e.id },415 totalEntries = activeEntries.size,416 onOpenList = { onNavigate(PeachNav.VaultList) },417 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },418 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },419 modifier = Modifier.align(Alignment.BottomCenter),420 )421 }422423 if (generatorOpen) {424 GeneratorSheet(425 copied = copiedField == "genDraft",426 onCopy = { draft -> vm.copy("genDraft", draft) },427 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },428 onDismiss = { generatorOpen = false },429 )430 }431 }432 }433}434435@Composable436private fun EntryDetailTopBar(437 entry: com.peachpasswords.vault.VaultEntry?,438 confirmingDelete: Boolean,439 onBack: (() -> Unit)?,440 onLock: () -> Unit,441 onDelete: () -> Unit,442 onAddEntry: () -> Unit,443 onSettings: () -> Unit,444 onOpenNotes: () -> Unit,445) {446 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }447 PeachTopBar(448 title = when {449 confirmingDelete && entry != null -> "delete \"${entry.title}\"?"450 451 452 453 454 onBack == null -> "peach"455 entry == null -> "entry"456 domain.isNotEmpty() -> domain457 else -> entry.title458 },459 leading = {460 if (onBack != null) {461 Icon(462 Icons.AutoMirrored.Rounded.ArrowBack,463 contentDescription = "back",464 tint = peachColors.T1,465 modifier = Modifier466 .size(40.dp)467 .clip(CircleShape)468 .clickable(onClick = onBack)469 .padding(PeachSpacing.sm),470 )471 } else {472 PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))473 }474 475 476 477 478 479 },480 actions = {481 482 483 484 485 486 487 488 489 ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)490 ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)491 ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)492 if (entry != null) {493 ChromeAction(494 Icons.Outlined.Delete,495 "delete",496 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,497 onClick = onDelete,498 )499 }500 ChromeAction(Icons.Outlined.Lock, "Lock vault", onClick = onLock)501 },502 )503}504505506@Composable507private fun ChromeAction(508 icon: androidx.compose.ui.graphics.vector.ImageVector,509 label: String,510 tint: Color = peachColors.T1,511 onClick: () -> Unit,512) {513 Icon(514 icon,515 contentDescription = label,516 tint = tint,517 modifier = Modifier518 .size(38.dp)519 .clip(CircleShape)520 .clickable(onClick = onClick)521 .padding(9.dp),522 )523}524525@Composable526private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {527 val accent = LocalPeachAccent.current528 var editing by remember(title) { mutableStateOf(false) }529 var draft by remember(title) { mutableStateOf(title) }530 if (!editing) {531 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {532 CharEnterText(533 text = title,534 style = PeachType.entryNameHero.copy(color = peachColors.T0),535 )536 }537 return538 }539 Column(Modifier.fillMaxWidth()) {540 BasicTextField(541 value = draft,542 onValueChange = { draft = it },543 singleLine = true,544 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),545 cursorBrush = SolidColor(accent),546 modifier = Modifier.fillMaxWidth().drawBehind {547 drawLine(548 color = accent,549 start = Offset(0f, size.height),550 end = Offset(size.width, size.height),551 strokeWidth = 1.dp.toPx(),552 )553 },554 )555 Spacer(Modifier.height(8.dp))556 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {557 Text(558 "✓ save", color = accent, style = PeachType.labelMono,559 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {560 onSave(draft)561 editing = false562 }.padding(vertical = 4.dp),563 )564 Text(565 "✗", color = peachColors.T2, style = PeachType.labelMono,566 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {567 draft = title568 editing = false569 }.padding(horizontal = 6.dp, vertical = 4.dp),570 )571 }572 }573}574575@Composable576private fun HomographWarning() {577 Row(578 Modifier.fillMaxWidth()579 .padding(top = 8.dp)580 .clip(RoundedCornerShape(8.dp))581 .background(peachColors.BreachBg)582 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))583 .padding(horizontal = 12.dp, vertical = 9.dp),584 verticalAlignment = Alignment.CenterVertically,585 ) {586 Text("!", color = peachColors.Breach, style = PeachType.labelMono)587 Spacer(Modifier.width(10.dp))588 Text(589 "This URL contains non-ASCII or punycode characters.",590 color = peachColors.Breach,591 style = PeachType.labelMono,592 modifier = Modifier.weight(1f),593 )594 }595}596597@Composable598private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {599 val passkey = entry.passkey ?: return600 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {601 Column(Modifier.weight(1f)) {602 Text(603 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,604 color = peachColors.T0,605 style = PeachType.body,606 )607 Text(608 listOfNotNull(609 passkey.rpId,610 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,611 ).joinToString(" · "),612 color = peachColors.T2,613 style = PeachType.labelMono,614 )615 Text(616 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",617 color = peachColors.T2,618 style = PeachType.labelMono,619 modifier = Modifier.padding(top = 3.dp),620 )621 }622 }623}624625@Composable626private fun CanaryDetailSection(627 entryId: String,628 modifiedAt: Long,629 canary: com.peachpasswords.vault.Canary?,630 isPro: Boolean,631 message: String?,632 testing: Boolean,633 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,634 onTest: (webhookUrl: String) -> Unit,635) {636 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }637 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }638 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }639 val gated = !isPro && canary == null640 FieldShell("canary alert") {641 Column(Modifier.weight(1f)) {642 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {643 Text(644 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",645 color = peachColors.T0,646 style = PeachType.body,647 modifier = Modifier.weight(1f),648 )649 if (gated) {650 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)651 }652 Text(653 if (enabled) "on" else "off",654 color = if (enabled) LocalPeachAccent.current else peachColors.T2,655 style = PeachType.labelMono,656 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {657 if (gated) {658 onSave(true, webhookUrl, label)659 } else {660 val next = !enabled661 enabled = next662 if (!next) onSave(false, webhookUrl, label)663 }664 }.padding(horizontal = 6.dp, vertical = 4.dp),665 )666 }667 Text(668 "Fires a silent webhook when this entry's password or username is copied.",669 color = peachColors.T2,670 style = PeachType.labelMono,671 modifier = Modifier.padding(top = 3.dp),672 )673 if (enabled) {674 Spacer(Modifier.height(8.dp))675 PeachField(676 label = "webhook url",677 value = webhookUrl,678 onChange = { webhookUrl = it },679 )680 Text(681 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",682 color = peachColors.T2,683 style = PeachType.labelMono,684 )685 PeachField(686 label = "label",687 value = label,688 onChange = { label = it },689 mono = false,690 )691 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {692 Text(693 "save",694 color = LocalPeachAccent.current,695 style = PeachType.labelMono,696 modifier = Modifier.clip(RoundedCornerShape(6.dp))697 .clickable { onSave(true, webhookUrl, label) }698 .padding(vertical = 4.dp),699 )700 Text(701 if (testing) "sending..." else "test ping",702 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,703 style = PeachType.labelMono,704 modifier = Modifier.clip(RoundedCornerShape(6.dp))705 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }706 .padding(vertical = 4.dp),707 )708 }709 }710 message?.let {711 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))712 }713 if (canary?.lastPinged != null || canary?.pingCount != null) {714 Text(715 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),716 color = peachColors.T2,717 style = PeachType.labelMono,718 modifier = Modifier.padding(top = 3.dp),719 )720 }721 }722 }723}724725@Composable726private fun EntryDetailSearchStrip(727 currentIndex: Int,728 totalEntries: Int,729 onOpenList: () -> Unit,730 onPrevious: () -> Unit,731 onNext: () -> Unit,732 modifier: Modifier = Modifier,733) {734 val accent = LocalPeachAccent.current735 val hasEntries = totalEntries > 0 && currentIndex >= 0736 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"737738 Column(739 modifier740 .fillMaxWidth()741 ) {742 743 744 Box(745 Modifier.fillMaxWidth().height(40.dp)746 .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),747 )748 Column(749 Modifier.fillMaxWidth()750 .background(peachColors.Bg)751 .navigationBarsPadding(),752 ) {753 Row(754 Modifier755 .fillMaxWidth()756 .padding(horizontal = 16.dp, vertical = 12.dp)757 .height(56.dp)758 .clip(RoundedCornerShape(16.dp))759 .background(peachColors.S1)760 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))761 .padding(horizontal = 12.dp),762 verticalAlignment = Alignment.CenterVertically,763 ) {764 Row(765 Modifier766 .weight(1f)767 .fillMaxHeight()768 .clickable(onClick = onOpenList)769 .pointerInput(onOpenList) {770 var opened = false771 detectVerticalDragGestures(772 onDragEnd = { opened = false },773 onDragCancel = { opened = false },774 ) { _, dragAmount ->775 if (!opened && dragAmount < -4f) {776 opened = true777 onOpenList()778 }779 }780 },781 verticalAlignment = Alignment.CenterVertically,782 ) {783 Icon(784 Icons.Rounded.Search,785 contentDescription = null,786 tint = accent,787 modifier = Modifier.size(14.dp),788 )789 Spacer(Modifier.width(8.dp))790 Text(791 "search vault...",792 color = peachColors.T2,793 style = PeachType.searchInput,794 modifier = Modifier.weight(1f),795 )796 Text(797 counter,798 color = peachColors.T2,799 style = PeachType.labelMono,800 )801 }802 Spacer(Modifier.width(8.dp))803 Icon(804 Icons.AutoMirrored.Rounded.ArrowBack,805 contentDescription = "Previous entry",806 tint = if (hasEntries) peachColors.T0 else peachColors.T2,807 modifier = Modifier808 .size(36.dp)809 .clip(RoundedCornerShape(8.dp))810 .clickable(enabled = hasEntries, onClick = onPrevious)811 .padding(8.dp),812 )813 Spacer(Modifier.width(4.dp))814 Icon(815 Icons.AutoMirrored.Rounded.ArrowForward,816 contentDescription = "Next entry",817 tint = if (hasEntries) peachColors.T0 else peachColors.T2,818 modifier = Modifier819 .size(36.dp)820 .clip(RoundedCornerShape(8.dp))821 .clickable(enabled = hasEntries, onClick = onNext)822 .padding(8.dp),823 )824 }825 }826 }827}828829830@Composable831internal fun FieldShell(832 label: String,833 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,834) {835 val hairline = peachColors.T3836 Column(837 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {838 drawRect(839 color = hairline,840 topLeft = Offset(0f, size.height - 1.dp.toPx()),841 size = Size(size.width, 1.dp.toPx()),842 )843 },844 ) {845 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))846 Spacer(Modifier.height(6.dp))847 Row(verticalAlignment = Alignment.CenterVertically, content = content)848 }849}850851852853854855internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }856857internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =858 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)859860internal fun shortCredential(credentialId: String): String {861 if (credentialId.length <= 12) return credentialId862 return credentialId.take(6) + "..." + credentialId.takeLast(4)863}864865internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {866 val count = pingCount ?: 0867 val countLabel = "$count total alert" + if (count == 1) "" else "s"868 if (lastPinged == null) return countLabel869 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)870 .format(java.util.Date(lastPinged))871 return "Last triggered: $date · $countLabel"872}873874private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {875 val raw = url876 if (!raw.isNullOrBlank()) {877 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')878 if (cleaned.isNotEmpty()) return cleaned879 }880 return ""881}