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 333 334 335 336 val history = e.passwordHistory.orEmpty()337 PasswordHistoryToggle(338 count = history.size,339 expanded = historyExpanded,340 onToggle = { historyExpanded = !historyExpanded },341 )342 AnimatedVisibility(visible = historyExpanded) {343 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {344 PasswordHistoryList(345 history = history,346 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },347 onCopy = { oldPw -> vm.copy("password", oldPw) },348 )349 }350 }351 if (e.hasTOTP()) {352 Spacer(Modifier.height(4.dp))353 TotpBandEditable(354 totpField = e.totp!!,355 copied = copiedField == "totp",356 onCopy = vm::copyTotp,357 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },358 )359 }360 }361 e.notes?.takeIf { it.isNotBlank() }?.let { v ->362 363 364 val notesAreHtml = isNoteHtml(v)365 EditableFieldRow(366 label = "notes", field = "notes", value = v,367 copied = copiedField == "notes",368 onCopy = { vm.copy("notes", notePlainText(v)) },369 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },370 mono = false, singleLine = false,371 displayText = noteRendered(v),372 draftText = noteEditorText(v),373 )374 }375 DetailTagRow(376 tags = e.tags.orEmpty(),377 allTags = allTags,378 onAddTag = vm::addTag,379 onRemoveTag = vm::removeTag,380 )381 if (!e.isNote() && !e.isCard()) {382 383 384 385 386 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }387 if (advancedExpanded) {388 CanaryDetailSection(389 entryId = e.id,390 modifiedAt = e.modifiedAt,391 canary = e.canary,392 isPro = isPro,393 message = canaryMessage,394 testing = canaryTesting,395 onSave = vm::saveCanary,396 onTest = vm::testCanary,397 )398 } else {399 Text(400 "+ advanced",401 color = LocalPeachAccent.current,402 style = PeachType.labelMono,403 modifier = Modifier404 .clip(RoundedCornerShape(6.dp))405 .clickable { advancedExpanded = true }406 .padding(vertical = 6.dp),407 )408 }409 }410411 Spacer(Modifier.height(PeachSpacing.xxl))412 }413414 if (e != null) {415 EntryDetailSearchStrip(416 currentIndex = activeEntries.indexOfFirst { it.id == e.id },417 totalEntries = activeEntries.size,418 onOpenList = { onNavigate(PeachNav.VaultList) },419 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },420 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },421 modifier = Modifier.align(Alignment.BottomCenter),422 )423 }424425 if (generatorOpen) {426 GeneratorSheet(427 copied = copiedField == "genDraft",428 onCopy = { draft -> vm.copy("genDraft", draft) },429 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },430 onDismiss = { generatorOpen = false },431 )432 }433 }434 }435}436437@Composable438private fun EntryDetailTopBar(439 entry: com.peachpasswords.vault.VaultEntry?,440 confirmingDelete: Boolean,441 onBack: (() -> Unit)?,442 onLock: () -> Unit,443 onDelete: () -> Unit,444 onAddEntry: () -> Unit,445 onSettings: () -> Unit,446 onOpenNotes: () -> Unit,447) {448 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }449 PeachTopBar(450 title = when {451 confirmingDelete && entry != null -> "delete \"${entry.title}\"?"452 453 454 455 456 onBack == null -> "peach"457 entry == null -> "entry"458 domain.isNotEmpty() -> domain459 else -> entry.title460 },461 leading = {462 if (onBack != null) {463 Icon(464 Icons.AutoMirrored.Rounded.ArrowBack,465 contentDescription = "back",466 tint = peachColors.T1,467 modifier = Modifier468 .size(40.dp)469 .clip(CircleShape)470 .clickable(onClick = onBack)471 .padding(PeachSpacing.sm),472 )473 } else {474 PeachMark(modifier = Modifier.padding(start = PeachSpacing.sm, end = PeachSpacing.xs))475 }476 477 478 479 480 481 },482 actions = {483 484 485 486 487 488 489 490 491 ChromeAction(Icons.Outlined.Description, "Notes", onClick = onOpenNotes)492 ChromeAction(Icons.Outlined.Settings, "settings", onClick = onSettings)493 ChromeAction(Icons.Outlined.Add, "new entry", onClick = onAddEntry)494 if (entry != null) {495 ChromeAction(496 Icons.Outlined.Delete,497 "delete",498 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,499 onClick = onDelete,500 )501 }502 ChromeAction(Icons.Outlined.Lock, "Lock vault", onClick = onLock)503 },504 )505}506507508@Composable509private fun ChromeAction(510 icon: androidx.compose.ui.graphics.vector.ImageVector,511 label: String,512 tint: Color = peachColors.T1,513 onClick: () -> Unit,514) {515 Icon(516 icon,517 contentDescription = label,518 tint = tint,519 modifier = Modifier520 .size(38.dp)521 .clip(CircleShape)522 .clickable(onClick = onClick)523 .padding(9.dp),524 )525}526527@Composable528private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {529 val accent = LocalPeachAccent.current530 var editing by remember(title) { mutableStateOf(false) }531 var draft by remember(title) { mutableStateOf(title) }532 if (!editing) {533 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {534 CharEnterText(535 text = title,536 style = PeachType.entryNameHero.copy(color = peachColors.T0),537 )538 }539 return540 }541 Column(Modifier.fillMaxWidth()) {542 BasicTextField(543 value = draft,544 onValueChange = { draft = it },545 singleLine = true,546 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),547 cursorBrush = SolidColor(accent),548 modifier = Modifier.fillMaxWidth().drawBehind {549 drawLine(550 color = accent,551 start = Offset(0f, size.height),552 end = Offset(size.width, size.height),553 strokeWidth = 1.dp.toPx(),554 )555 },556 )557 Spacer(Modifier.height(8.dp))558 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {559 Text(560 "✓ save", color = accent, style = PeachType.labelMono,561 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {562 onSave(draft)563 editing = false564 }.padding(vertical = 4.dp),565 )566 Text(567 "✗", color = peachColors.T2, style = PeachType.labelMono,568 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {569 draft = title570 editing = false571 }.padding(horizontal = 6.dp, vertical = 4.dp),572 )573 }574 }575}576577@Composable578private fun HomographWarning() {579 Row(580 Modifier.fillMaxWidth()581 .padding(top = 8.dp)582 .clip(RoundedCornerShape(8.dp))583 .background(peachColors.BreachBg)584 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))585 .padding(horizontal = 12.dp, vertical = 9.dp),586 verticalAlignment = Alignment.CenterVertically,587 ) {588 Text("!", color = peachColors.Breach, style = PeachType.labelMono)589 Spacer(Modifier.width(10.dp))590 Text(591 "This URL contains non-ASCII or punycode characters.",592 color = peachColors.Breach,593 style = PeachType.labelMono,594 modifier = Modifier.weight(1f),595 )596 }597}598599@Composable600private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {601 val passkey = entry.passkey ?: return602 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {603 Column(Modifier.weight(1f)) {604 Text(605 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,606 color = peachColors.T0,607 style = PeachType.body,608 )609 Text(610 listOfNotNull(611 passkey.rpId,612 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,613 ).joinToString(" · "),614 color = peachColors.T2,615 style = PeachType.labelMono,616 )617 Text(618 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",619 color = peachColors.T2,620 style = PeachType.labelMono,621 modifier = Modifier.padding(top = 3.dp),622 )623 }624 }625}626627@Composable628private fun CanaryDetailSection(629 entryId: String,630 modifiedAt: Long,631 canary: com.peachpasswords.vault.Canary?,632 isPro: Boolean,633 message: String?,634 testing: Boolean,635 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,636 onTest: (webhookUrl: String) -> Unit,637) {638 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }639 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }640 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }641 val gated = !isPro && canary == null642 FieldShell("canary alert") {643 Column(Modifier.weight(1f)) {644 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {645 Text(646 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",647 color = peachColors.T0,648 style = PeachType.body,649 modifier = Modifier.weight(1f),650 )651 if (gated) {652 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)653 }654 Text(655 if (enabled) "on" else "off",656 color = if (enabled) LocalPeachAccent.current else peachColors.T2,657 style = PeachType.labelMono,658 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {659 if (gated) {660 onSave(true, webhookUrl, label)661 } else {662 val next = !enabled663 enabled = next664 if (!next) onSave(false, webhookUrl, label)665 }666 }.padding(horizontal = 6.dp, vertical = 4.dp),667 )668 }669 Text(670 "Fires a silent webhook when this entry's password or username is copied.",671 color = peachColors.T2,672 style = PeachType.labelMono,673 modifier = Modifier.padding(top = 3.dp),674 )675 if (enabled) {676 Spacer(Modifier.height(8.dp))677 PeachField(678 label = "webhook url",679 value = webhookUrl,680 onChange = { webhookUrl = it },681 )682 Text(683 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",684 color = peachColors.T2,685 style = PeachType.labelMono,686 )687 PeachField(688 label = "label",689 value = label,690 onChange = { label = it },691 mono = false,692 )693 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {694 Text(695 "save",696 color = LocalPeachAccent.current,697 style = PeachType.labelMono,698 modifier = Modifier.clip(RoundedCornerShape(6.dp))699 .clickable { onSave(true, webhookUrl, label) }700 .padding(vertical = 4.dp),701 )702 Text(703 if (testing) "sending..." else "test ping",704 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,705 style = PeachType.labelMono,706 modifier = Modifier.clip(RoundedCornerShape(6.dp))707 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }708 .padding(vertical = 4.dp),709 )710 }711 }712 message?.let {713 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))714 }715 if (canary?.lastPinged != null || canary?.pingCount != null) {716 Text(717 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),718 color = peachColors.T2,719 style = PeachType.labelMono,720 modifier = Modifier.padding(top = 3.dp),721 )722 }723 }724 }725}726727@Composable728private fun EntryDetailSearchStrip(729 currentIndex: Int,730 totalEntries: Int,731 onOpenList: () -> Unit,732 onPrevious: () -> Unit,733 onNext: () -> Unit,734 modifier: Modifier = Modifier,735) {736 val accent = LocalPeachAccent.current737 val hasEntries = totalEntries > 0 && currentIndex >= 0738 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"739740 Column(741 modifier742 .fillMaxWidth()743 ) {744 745 746 Box(747 Modifier.fillMaxWidth().height(40.dp)748 .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),749 )750 Column(751 Modifier.fillMaxWidth()752 .background(peachColors.Bg)753 .navigationBarsPadding(),754 ) {755 Row(756 Modifier757 .fillMaxWidth()758 .padding(horizontal = 16.dp, vertical = 12.dp)759 .height(56.dp)760 .clip(RoundedCornerShape(16.dp))761 .background(peachColors.S1)762 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))763 .padding(horizontal = 12.dp),764 verticalAlignment = Alignment.CenterVertically,765 ) {766 Row(767 Modifier768 .weight(1f)769 .fillMaxHeight()770 .clickable(onClick = onOpenList)771 .pointerInput(onOpenList) {772 var opened = false773 detectVerticalDragGestures(774 onDragEnd = { opened = false },775 onDragCancel = { opened = false },776 ) { _, dragAmount ->777 if (!opened && dragAmount < -4f) {778 opened = true779 onOpenList()780 }781 }782 },783 verticalAlignment = Alignment.CenterVertically,784 ) {785 Icon(786 Icons.Rounded.Search,787 contentDescription = null,788 tint = accent,789 modifier = Modifier.size(14.dp),790 )791 Spacer(Modifier.width(8.dp))792 Text(793 "search vault...",794 color = peachColors.T2,795 style = PeachType.searchInput,796 modifier = Modifier.weight(1f),797 )798 Text(799 counter,800 color = peachColors.T2,801 style = PeachType.labelMono,802 )803 }804 Spacer(Modifier.width(8.dp))805 Icon(806 Icons.AutoMirrored.Rounded.ArrowBack,807 contentDescription = "Previous entry",808 tint = if (hasEntries) peachColors.T0 else peachColors.T2,809 modifier = Modifier810 .size(36.dp)811 .clip(RoundedCornerShape(8.dp))812 .clickable(enabled = hasEntries, onClick = onPrevious)813 .padding(8.dp),814 )815 Spacer(Modifier.width(4.dp))816 Icon(817 Icons.AutoMirrored.Rounded.ArrowForward,818 contentDescription = "Next entry",819 tint = if (hasEntries) peachColors.T0 else peachColors.T2,820 modifier = Modifier821 .size(36.dp)822 .clip(RoundedCornerShape(8.dp))823 .clickable(enabled = hasEntries, onClick = onNext)824 .padding(8.dp),825 )826 }827 }828 }829}830831832@Composable833internal fun FieldShell(834 label: String,835 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,836) {837 val hairline = peachColors.T3838 Column(839 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {840 drawRect(841 color = hairline,842 topLeft = Offset(0f, size.height - 1.dp.toPx()),843 size = Size(size.width, 1.dp.toPx()),844 )845 },846 ) {847 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))848 Spacer(Modifier.height(6.dp))849 Row(verticalAlignment = Alignment.CenterVertically, content = content)850 }851}852853854855856857internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }858859internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =860 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)861862internal fun shortCredential(credentialId: String): String {863 if (credentialId.length <= 12) return credentialId864 return credentialId.take(6) + "..." + credentialId.takeLast(4)865}866867internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {868 val count = pingCount ?: 0869 val countLabel = "$count total alert" + if (count == 1) "" else "s"870 if (lastPinged == null) return countLabel871 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)872 .format(java.util.Date(lastPinged))873 return "Last triggered: $date · $countLabel"874}875876private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {877 val raw = url878 if (!raw.isNullOrBlank()) {879 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')880 if (cleaned.isNotEmpty()) return cleaned881 }882 return ""883}