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.DeleteOutline45import androidx.compose.material.icons.outlined.Lock46import androidx.compose.material3.Icon47import androidx.compose.material3.Text48import androidx.compose.runtime.Composable49import androidx.compose.runtime.getValue50import androidx.compose.runtime.mutableStateOf51import androidx.compose.runtime.produceState52import androidx.compose.runtime.remember53import androidx.compose.runtime.setValue54import androidx.compose.ui.Alignment55import androidx.compose.ui.Modifier56import androidx.compose.ui.draw.clip57import androidx.compose.ui.draw.clipToBounds58import androidx.compose.ui.draw.drawBehind59import androidx.compose.ui.geometry.Offset60import androidx.compose.ui.geometry.Size61import androidx.compose.ui.graphics.Brush62import androidx.compose.ui.graphics.Color63import androidx.compose.ui.graphics.SolidColor64import androidx.compose.ui.graphics.graphicsLayer65import androidx.compose.ui.platform.LocalContext66import androidx.compose.ui.input.pointer.pointerInput67import androidx.compose.ui.text.TextStyle68import androidx.compose.ui.text.font.FontStyle69import androidx.compose.ui.text.style.TextOverflow70import androidx.compose.ui.unit.dp71import androidx.compose.ui.unit.em72import androidx.compose.ui.unit.sp73import androidx.compose.runtime.collectAsState74import androidx.fragment.app.FragmentActivity75import androidx.hilt.navigation.compose.hiltViewModel76import com.peachpasswords.shared.detectHomograph77import com.peachpasswords.ui.PeachNav78import com.peachpasswords.ui.components.CharEnterText79import com.peachpasswords.ui.components.CopyButton80import com.peachpasswords.ui.components.PeachEmptyOrEndState81import com.peachpasswords.ui.components.PeachTopBar82import com.peachpasswords.ui.components.SecureFlagEffect83import com.peachpasswords.ui.components.TOTPRing84import com.peachpasswords.ui.lock.Biometrics85import com.peachpasswords.ui.theme.LocalPeachAccent86import com.peachpasswords.ui.theme.LocalReduceMotion87import com.peachpasswords.ui.theme.PeachSpacing88import com.peachpasswords.ui.theme.peachColors89import com.peachpasswords.ui.theme.PeachType90import com.peachpasswords.ui.theme.ShellBackground91import com.peachpasswords.vault.TOTPGenerator92import com.peachpasswords.vault.hasTOTP93import com.peachpasswords.vault.hasPasskey94import com.peachpasswords.vault.isBreach95import com.peachpasswords.vault.isCard96import com.peachpasswords.vault.isNote97import com.peachpasswords.vault.isPasskeyOnly9899@Composable100fun EntryDetailScreen(101 id: String,102 onNavigate: (PeachNav) -> Unit,103 104 105 106 onBack: (() -> Unit)?,107 vm: EntryDetailViewModel = hiltViewModel(),108) {109 val entry by vm.entry.collectAsState()110 val activeEntries by vm.activeEntries.collectAsState()111 val identitySuggestions by vm.identitySuggestions.collectAsState()112 val allTags by vm.allTags.collectAsState()113 val isPro by vm.isPro.collectAsState()114 val cvcRevealed by vm.cvcRevealed.collectAsState()115 var cardRevealed by remember { mutableStateOf(false) }116 val copiedField by vm.copiedField.collectAsState()117 val canaryMessage by vm.canaryMessage.collectAsState()118 val canaryTesting by vm.canaryTesting.collectAsState()119 val context = LocalContext.current120 val accent = LocalPeachAccent.current121 var confirmingDelete by remember { mutableStateOf(false) }122 var generatorOpen by remember { mutableStateOf(false) }123 var historyExpanded by remember { mutableStateOf(false) }124125 SecureFlagEffect()126127 val e = entry128 ShellBackground {129 Box(130 Modifier.fillMaxSize().statusBarsPadding()131 132 133 134 135 .pointerInput(vm) {136 var dragAccum = 0f137 detectHorizontalDragGestures(138 onDragStart = { dragAccum = 0f },139 onDragEnd = {140 when {141 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)142 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)143 }144 dragAccum = 0f145 },146 onDragCancel = { dragAccum = 0f },147 ) { _, dragAmount -> dragAccum += dragAmount }148 },149 ) {150 Column(151 Modifier.fillMaxSize()152 .padding(bottom = 100.dp)153 .verticalScroll(rememberScrollState())154 .padding(horizontal = PeachSpacing.xl),155 ) {156 EntryDetailTopBar(157 entry = e,158 confirmingDelete = confirmingDelete,159 onBack = onBack,160 onLock = vm::lock,161 onDelete = {162 if (confirmingDelete) {163 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })164 } else {165 confirmingDelete = true166 }167 },168 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },169 onSettings = { onNavigate(PeachNav.Settings) },170 )171 Spacer(Modifier.height(PeachSpacing.lg))172173 if (e == null) {174 PeachEmptyOrEndState(175 message = "entry not found",176 detail = "It may have been moved or deleted.",177 modifier = Modifier.padding(top = PeachSpacing.xxl),178 )179 return@Column180 }181182 183 if (e.isBreach()) {184 Row(185 Modifier.fillMaxWidth()186 .background(peachColors.BreachBg)187 .padding(horizontal = 16.dp, vertical = 8.dp),188 verticalAlignment = Alignment.CenterVertically,189 ) {190 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)191 Spacer(Modifier.width(10.dp))192 Text(193 "Password found in a data breach — change it now",194 color = peachColors.Breach, style = PeachType.labelMono,195 modifier = Modifier.weight(1f),196 )197 Text(198 "Change password",199 color = accent, style = PeachType.labelMono,200 modifier = Modifier.clip(RoundedCornerShape(6.dp))201 .clickable { generatorOpen = true }202 .padding(horizontal = 6.dp, vertical = 4.dp),203 )204 }205 }206 if (entryHasHomographWarning(e)) {207 HomographWarning()208 }209210 211 212 213 Box(Modifier.fillMaxWidth().clipToBounds()) {214 Text(215 text = ghostWatermarkText(e.title),216 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),217 color = peachColors.T0.copy(alpha = 0.03f),218 maxLines = 1,219 softWrap = false,220 overflow = TextOverflow.Clip,221 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),222 )223 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {224 EditableTitleHero(225 title = e.title,226 onSave = { vm.updateField("title", it) },227 )228 }229 }230 Spacer(Modifier.height(PeachSpacing.lg))231232 if (e.isCard()) {233 CardDetailFields(234 entry = e,235 cardRevealed = cardRevealed,236 cvcRevealed = cvcRevealed,237 copiedField = copiedField,238 onToggleCardReveal = { cardRevealed = !cardRevealed },239 onRevealCvc = {240 val activity = context as? FragmentActivity241 if (activity != null && Biometrics.isAvailable(context)) {242 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)243 } else vm.revealCvc()244 },245 onMaskCvc = vm::maskCvc,246 onCopy = vm::copy,247 onSaveCard = vm::updateCard,248 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },249 )250 } else {251 if (!e.isNote()) {252 EditableFieldRow(253 label = "url", field = "url", value = e.url.orEmpty(),254 copied = copiedField == "url",255 onCopy = { vm.copy("url", e.url) },256 onSave = { vm.updateField("url", it) },257 placeholder = "add url",258 )259 }260 261 262 263 264 265 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }266 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }267 if (showUsernameField) {268 EditableFieldRow(269 label = "username", field = "username", value = e.username.orEmpty(),270 copied = copiedField == "username",271 onCopy = { vm.copy("username", e.username) },272 onSave = { vm.updateField("username", it) },273 suggestions = identitySuggestions,274 placeholder = "add username",275 )276 }277 if (showEmailField) {278 EditableFieldRow(279 label = "email", field = "email", value = e.email.orEmpty(),280 copied = copiedField == "email",281 onCopy = { vm.copy("email", e.email) },282 onSave = { vm.updateField("email", it) },283 suggestions = identitySuggestions,284 placeholder = "add email",285 )286 }287 if (!showUsernameField || !showEmailField) {288 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {289 if (!showUsernameField) {290 Text(291 "+ add username", color = accent, style = PeachType.labelMono,292 modifier = Modifier293 .clip(RoundedCornerShape(6.dp))294 .clickable { showUsernameField = true }295 .padding(vertical = 6.dp),296 )297 }298 if (!showEmailField) {299 Text(300 "+ add email", color = accent, style = PeachType.labelMono,301 modifier = Modifier302 .clip(RoundedCornerShape(6.dp))303 .clickable { showEmailField = true }304 .padding(vertical = 6.dp),305 )306 }307 }308 }309 AdditionalUrlRows(310 urls = e.additionalUrls.orEmpty(),311 copiedField = copiedField,312 onCopy = { key, value -> vm.copy(key, value) },313 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },314 onAdd = vm::addAdditionalUrl,315 )316 if (e.hasPasskey()) {317 PasskeyDetailSection(e)318 }319 e.password?.takeIf { it.isNotBlank() }?.let { pw ->320 EditablePasswordRow(321 value = pw,322 copied = copiedField == "password",323 entryTitle = e.title,324 onCopy = { vm.copy("password", pw) },325 onSave = { vm.updateField("password", it) },326 onGenerate = { generatorOpen = true },327 )328 }329 val history = e.passwordHistory.orEmpty()330 if (history.isNotEmpty()) {331 PasswordHistoryToggle(332 count = history.size,333 expanded = historyExpanded,334 onToggle = { historyExpanded = !historyExpanded },335 )336 AnimatedVisibility(visible = historyExpanded) {337 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {338 PasswordHistoryList(339 history = history,340 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },341 onCopy = { oldPw -> vm.copy("password", oldPw) },342 )343 }344 }345 }346 if (e.hasTOTP()) {347 Spacer(Modifier.height(4.dp))348 TotpBandEditable(349 totpField = e.totp!!,350 copied = copiedField == "totp",351 onCopy = vm::copyTotp,352 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },353 )354 }355 }356 e.notes?.takeIf { it.isNotBlank() }?.let { v ->357 EditableFieldRow(358 label = "notes", field = "notes", value = v,359 copied = copiedField == "notes",360 onCopy = { vm.copy("notes", v) },361 onSave = { vm.updateField("notes", it) },362 mono = false, singleLine = false,363 )364 }365 DetailTagRow(366 tags = e.tags.orEmpty(),367 allTags = allTags,368 onAddTag = vm::addTag,369 onRemoveTag = vm::removeTag,370 )371 if (!e.isNote() && !e.isCard()) {372 373 374 375 376 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }377 if (advancedExpanded) {378 CanaryDetailSection(379 entryId = e.id,380 modifiedAt = e.modifiedAt,381 canary = e.canary,382 isPro = isPro,383 message = canaryMessage,384 testing = canaryTesting,385 onSave = vm::saveCanary,386 onTest = vm::testCanary,387 )388 } else {389 Text(390 "+ advanced",391 color = LocalPeachAccent.current,392 style = PeachType.labelMono,393 modifier = Modifier394 .clip(RoundedCornerShape(6.dp))395 .clickable { advancedExpanded = true }396 .padding(vertical = 6.dp),397 )398 }399 }400401 Spacer(Modifier.height(PeachSpacing.xxl))402 }403404 if (e != null) {405 EntryDetailSearchStrip(406 currentIndex = activeEntries.indexOfFirst { it.id == e.id },407 totalEntries = activeEntries.size,408 onOpenList = { onNavigate(PeachNav.VaultList) },409 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },410 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },411 modifier = Modifier.align(Alignment.BottomCenter),412 )413 }414415 if (generatorOpen) {416 GeneratorSheet(417 copied = copiedField == "genDraft",418 onCopy = { draft -> vm.copy("genDraft", draft) },419 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },420 onDismiss = { generatorOpen = false },421 )422 }423 }424 }425}426427@Composable428private fun EntryDetailTopBar(429 entry: com.peachpasswords.vault.VaultEntry?,430 confirmingDelete: Boolean,431 onBack: (() -> Unit)?,432 onLock: () -> Unit,433 onDelete: () -> Unit,434 onAddEntry: () -> Unit,435 onSettings: () -> Unit,436) {437 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }438 PeachTopBar(439 title = when {440 entry == null -> "entry"441 confirmingDelete -> "delete \"${entry.title}\"?"442 domain.isNotEmpty() -> domain443 else -> entry.title444 },445 leading = {446 if (onBack != null) {447 Icon(448 Icons.AutoMirrored.Rounded.ArrowBack,449 contentDescription = "back",450 tint = peachColors.T1,451 modifier = Modifier452 .size(40.dp)453 .clip(CircleShape)454 .clickable(onClick = onBack)455 .padding(PeachSpacing.sm),456 )457 } else {458 459 460 461 462 463 Icon(464 Icons.Outlined.Lock,465 contentDescription = "Lock vault",466 tint = peachColors.T1,467 modifier = Modifier468 .size(40.dp)469 .clip(CircleShape)470 .clickable(onClick = onLock)471 .padding(PeachSpacing.sm),472 )473 }474 475 476 477 478 479 },480 actions = {481 482 483 484 485 486 if (onBack == null) {487 Icon(488 Icons.Rounded.Add,489 contentDescription = "new entry",490 tint = peachColors.T1,491 modifier = Modifier492 .size(40.dp)493 .clip(CircleShape)494 .clickable(onClick = onAddEntry)495 .padding(PeachSpacing.sm),496 )497 Icon(498 Icons.Rounded.Settings,499 contentDescription = "settings",500 tint = peachColors.T1,501 modifier = Modifier502 .size(40.dp)503 .clip(CircleShape)504 .clickable(onClick = onSettings)505 .padding(PeachSpacing.sm),506 )507 }508 if (entry != null) {509 Icon(510 Icons.Outlined.DeleteOutline,511 contentDescription = "delete",512 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,513 modifier = Modifier514 .size(40.dp)515 .clip(CircleShape)516 .clickable(onClick = onDelete)517 .padding(PeachSpacing.sm),518 )519 }520 },521 )522}523524@Composable525private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {526 val accent = LocalPeachAccent.current527 var editing by remember(title) { mutableStateOf(false) }528 var draft by remember(title) { mutableStateOf(title) }529 if (!editing) {530 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {531 CharEnterText(532 text = title,533 style = PeachType.entryNameHero.copy(color = peachColors.T0),534 )535 }536 return537 }538 Column(Modifier.fillMaxWidth()) {539 BasicTextField(540 value = draft,541 onValueChange = { draft = it },542 singleLine = true,543 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),544 cursorBrush = SolidColor(accent),545 modifier = Modifier.fillMaxWidth().drawBehind {546 drawLine(547 color = accent,548 start = Offset(0f, size.height),549 end = Offset(size.width, size.height),550 strokeWidth = 1.dp.toPx(),551 )552 },553 )554 Spacer(Modifier.height(8.dp))555 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {556 Text(557 "✓ save", color = accent, style = PeachType.labelMono,558 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {559 onSave(draft)560 editing = false561 }.padding(vertical = 4.dp),562 )563 Text(564 "✗", color = peachColors.T2, style = PeachType.labelMono,565 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {566 draft = title567 editing = false568 }.padding(horizontal = 6.dp, vertical = 4.dp),569 )570 }571 }572}573574@Composable575private fun HomographWarning() {576 Row(577 Modifier.fillMaxWidth()578 .padding(top = 8.dp)579 .clip(RoundedCornerShape(8.dp))580 .background(peachColors.BreachBg)581 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))582 .padding(horizontal = 12.dp, vertical = 9.dp),583 verticalAlignment = Alignment.CenterVertically,584 ) {585 Text("!", color = peachColors.Breach, style = PeachType.labelMono)586 Spacer(Modifier.width(10.dp))587 Text(588 "This URL contains non-ASCII or punycode characters.",589 color = peachColors.Breach,590 style = PeachType.labelMono,591 modifier = Modifier.weight(1f),592 )593 }594}595596@Composable597private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {598 val passkey = entry.passkey ?: return599 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {600 Column(Modifier.weight(1f)) {601 Text(602 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,603 color = peachColors.T0,604 style = PeachType.body,605 )606 Text(607 listOfNotNull(608 passkey.rpId,609 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,610 ).joinToString(" · "),611 color = peachColors.T2,612 style = PeachType.labelMono,613 )614 Text(615 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",616 color = peachColors.T2,617 style = PeachType.labelMono,618 modifier = Modifier.padding(top = 3.dp),619 )620 }621 }622}623624@Composable625private fun CanaryDetailSection(626 entryId: String,627 modifiedAt: Long,628 canary: com.peachpasswords.vault.Canary?,629 isPro: Boolean,630 message: String?,631 testing: Boolean,632 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,633 onTest: (webhookUrl: String) -> Unit,634) {635 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }636 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }637 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }638 val gated = !isPro && canary == null639 FieldShell("canary alert") {640 Column(Modifier.weight(1f)) {641 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {642 Text(643 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",644 color = peachColors.T0,645 style = PeachType.body,646 modifier = Modifier.weight(1f),647 )648 if (gated) {649 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)650 }651 Text(652 if (enabled) "on" else "off",653 color = if (enabled) LocalPeachAccent.current else peachColors.T2,654 style = PeachType.labelMono,655 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {656 if (gated) {657 onSave(true, webhookUrl, label)658 } else {659 val next = !enabled660 enabled = next661 if (!next) onSave(false, webhookUrl, label)662 }663 }.padding(horizontal = 6.dp, vertical = 4.dp),664 )665 }666 Text(667 "Fires a silent webhook when this entry's password or username is copied.",668 color = peachColors.T2,669 style = PeachType.labelMono,670 modifier = Modifier.padding(top = 3.dp),671 )672 if (enabled) {673 Spacer(Modifier.height(8.dp))674 PeachField(675 label = "webhook url",676 value = webhookUrl,677 onChange = { webhookUrl = it },678 )679 Text(680 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",681 color = peachColors.T2,682 style = PeachType.labelMono,683 )684 PeachField(685 label = "label",686 value = label,687 onChange = { label = it },688 mono = false,689 )690 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {691 Text(692 "save",693 color = LocalPeachAccent.current,694 style = PeachType.labelMono,695 modifier = Modifier.clip(RoundedCornerShape(6.dp))696 .clickable { onSave(true, webhookUrl, label) }697 .padding(vertical = 4.dp),698 )699 Text(700 if (testing) "sending..." else "test ping",701 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,702 style = PeachType.labelMono,703 modifier = Modifier.clip(RoundedCornerShape(6.dp))704 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }705 .padding(vertical = 4.dp),706 )707 }708 }709 message?.let {710 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))711 }712 if (canary?.lastPinged != null || canary?.pingCount != null) {713 Text(714 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),715 color = peachColors.T2,716 style = PeachType.labelMono,717 modifier = Modifier.padding(top = 3.dp),718 )719 }720 }721 }722}723724@Composable725private fun EntryDetailSearchStrip(726 currentIndex: Int,727 totalEntries: Int,728 onOpenList: () -> Unit,729 onPrevious: () -> Unit,730 onNext: () -> Unit,731 modifier: Modifier = Modifier,732) {733 val accent = LocalPeachAccent.current734 val hasEntries = totalEntries > 0 && currentIndex >= 0735 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"736737 Column(738 modifier739 .fillMaxWidth()740 .navigationBarsPadding()741 .padding(horizontal = 16.dp, vertical = 12.dp),742 ) {743 Row(744 Modifier745 .fillMaxWidth()746 .height(56.dp)747 .clip(RoundedCornerShape(16.dp))748 .background(peachColors.S1)749 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))750 .padding(horizontal = 12.dp),751 verticalAlignment = Alignment.CenterVertically,752 ) {753 Row(754 Modifier755 .weight(1f)756 .fillMaxHeight()757 .clickable(onClick = onOpenList)758 .pointerInput(onOpenList) {759 var opened = false760 detectVerticalDragGestures(761 onDragEnd = { opened = false },762 onDragCancel = { opened = false },763 ) { _, dragAmount ->764 if (!opened && dragAmount < -4f) {765 opened = true766 onOpenList()767 }768 }769 },770 verticalAlignment = Alignment.CenterVertically,771 ) {772 Icon(773 Icons.Rounded.Search,774 contentDescription = null,775 tint = accent,776 modifier = Modifier.size(14.dp),777 )778 Spacer(Modifier.width(8.dp))779 Text(780 "search vault...",781 color = peachColors.T2,782 style = PeachType.searchInput,783 modifier = Modifier.weight(1f),784 )785 Text(786 counter,787 color = peachColors.T2,788 style = PeachType.labelMono,789 )790 }791 Spacer(Modifier.width(8.dp))792 Icon(793 Icons.AutoMirrored.Rounded.ArrowBack,794 contentDescription = "Previous entry",795 tint = if (hasEntries) peachColors.T0 else peachColors.T2,796 modifier = Modifier797 .size(38.dp)798 .clip(CircleShape)799 .clickable(enabled = hasEntries, onClick = onPrevious)800 .padding(9.dp),801 )802 Icon(803 Icons.AutoMirrored.Rounded.ArrowForward,804 contentDescription = "Next entry",805 tint = if (hasEntries) peachColors.T0 else peachColors.T2,806 modifier = Modifier807 .size(38.dp)808 .clip(CircleShape)809 .clickable(enabled = hasEntries, onClick = onNext)810 .padding(9.dp),811 )812 }813 }814}815816817@Composable818internal fun FieldShell(819 label: String,820 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,821) {822 val hairline = peachColors.T3823 Column(824 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {825 drawRect(826 color = hairline,827 topLeft = Offset(0f, size.height - 1.dp.toPx()),828 size = Size(size.width, 1.dp.toPx()),829 )830 },831 ) {832 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))833 Spacer(Modifier.height(6.dp))834 Row(verticalAlignment = Alignment.CenterVertically, content = content)835 }836}837838839840841842internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }843844internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =845 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)846847internal fun shortCredential(credentialId: String): String {848 if (credentialId.length <= 12) return credentialId849 return credentialId.take(6) + "..." + credentialId.takeLast(4)850}851852internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {853 val count = pingCount ?: 0854 val countLabel = "$count total alert" + if (count == 1) "" else "s"855 if (lastPinged == null) return countLabel856 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)857 .format(java.util.Date(lastPinged))858 return "Last triggered: $date · $countLabel"859}860861private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {862 val raw = url863 if (!raw.isNullOrBlank()) {864 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')865 if (cleaned.isNotEmpty()) return cleaned866 }867 return ""868}