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.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.material.icons.outlined.Lock44import androidx.compose.material3.Icon45import androidx.compose.material3.Text46import androidx.compose.runtime.Composable47import androidx.compose.runtime.getValue48import androidx.compose.runtime.mutableStateOf49import androidx.compose.runtime.produceState50import androidx.compose.runtime.remember51import androidx.compose.runtime.setValue52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.clipToBounds56import androidx.compose.ui.draw.drawBehind57import androidx.compose.ui.geometry.Offset58import androidx.compose.ui.geometry.Size59import androidx.compose.ui.graphics.Brush60import androidx.compose.ui.graphics.Color61import androidx.compose.ui.graphics.SolidColor62import androidx.compose.ui.graphics.graphicsLayer63import androidx.compose.ui.platform.LocalContext64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.text.TextStyle66import androidx.compose.ui.text.font.FontStyle67import androidx.compose.ui.text.style.TextOverflow68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import androidx.compose.ui.unit.sp71import androidx.compose.runtime.collectAsState72import androidx.fragment.app.FragmentActivity73import androidx.hilt.navigation.compose.hiltViewModel74import com.peachpasswords.shared.detectHomograph75import com.peachpasswords.ui.PeachNav76import com.peachpasswords.ui.components.CharEnterText77import com.peachpasswords.ui.components.CopyButton78import com.peachpasswords.ui.components.FaviconInitials79import com.peachpasswords.ui.components.PeachEmptyOrEndState80import com.peachpasswords.ui.components.PeachTopBar81import com.peachpasswords.ui.components.SecureFlagEffect82import com.peachpasswords.ui.components.TOTPRing83import com.peachpasswords.ui.lock.Biometrics84import com.peachpasswords.ui.theme.LocalPeachAccent85import com.peachpasswords.ui.theme.LocalReduceMotion86import com.peachpasswords.ui.theme.PeachSpacing87import com.peachpasswords.ui.theme.peachColors88import com.peachpasswords.ui.theme.PeachType89import com.peachpasswords.ui.theme.ShellBackground90import com.peachpasswords.vault.TOTPGenerator91import com.peachpasswords.vault.hasTOTP92import com.peachpasswords.vault.hasPasskey93import com.peachpasswords.vault.isBreach94import com.peachpasswords.vault.isCard95import com.peachpasswords.vault.isNote96import com.peachpasswords.vault.isPasskeyOnly9798@Composable99fun EntryDetailScreen(100 id: String,101 onNavigate: (PeachNav) -> Unit,102 103 104 105 onBack: (() -> Unit)?,106 vm: EntryDetailViewModel = hiltViewModel(),107) {108 val entry by vm.entry.collectAsState()109 val activeEntries by vm.activeEntries.collectAsState()110 val identitySuggestions by vm.identitySuggestions.collectAsState()111 val allTags by vm.allTags.collectAsState()112 val isPro by vm.isPro.collectAsState()113 val cvcRevealed by vm.cvcRevealed.collectAsState()114 var cardRevealed by remember { mutableStateOf(false) }115 val copiedField by vm.copiedField.collectAsState()116 val canaryMessage by vm.canaryMessage.collectAsState()117 val canaryTesting by vm.canaryTesting.collectAsState()118 val context = LocalContext.current119 val accent = LocalPeachAccent.current120 var confirmingDelete by remember { mutableStateOf(false) }121 var generatorOpen by remember { mutableStateOf(false) }122 var historyExpanded by remember { mutableStateOf(false) }123124 SecureFlagEffect()125126 val e = entry127 ShellBackground {128 Box(129 Modifier.fillMaxSize().statusBarsPadding()130 131 132 133 134 .pointerInput(vm) {135 var dragAccum = 0f136 detectHorizontalDragGestures(137 onDragStart = { dragAccum = 0f },138 onDragEnd = {139 when {140 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)141 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)142 }143 dragAccum = 0f144 },145 onDragCancel = { dragAccum = 0f },146 ) { _, dragAmount -> dragAccum += dragAmount }147 },148 ) {149 Column(150 Modifier.fillMaxSize()151 .padding(bottom = 100.dp)152 .verticalScroll(rememberScrollState())153 .padding(horizontal = PeachSpacing.xl),154 ) {155 EntryDetailTopBar(156 entry = e,157 confirmingDelete = confirmingDelete,158 onBack = onBack,159 onLock = vm::lock,160 onDelete = {161 if (confirmingDelete) {162 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })163 } else {164 confirmingDelete = true165 }166 },167 )168 Spacer(Modifier.height(PeachSpacing.lg))169170 if (e == null) {171 PeachEmptyOrEndState(172 message = "entry not found",173 detail = "It may have been moved or deleted.",174 modifier = Modifier.padding(top = PeachSpacing.xxl),175 )176 return@Column177 }178179 180 if (e.isBreach()) {181 Row(182 Modifier.fillMaxWidth()183 .background(peachColors.BreachBg)184 .padding(horizontal = 16.dp, vertical = 8.dp),185 verticalAlignment = Alignment.CenterVertically,186 ) {187 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)188 Spacer(Modifier.width(10.dp))189 Text(190 "Password found in a data breach — change it now",191 color = peachColors.Breach, style = PeachType.labelMono,192 modifier = Modifier.weight(1f),193 )194 Text(195 "Change password",196 color = accent, style = PeachType.labelMono,197 modifier = Modifier.clip(RoundedCornerShape(6.dp))198 .clickable { generatorOpen = true }199 .padding(horizontal = 6.dp, vertical = 4.dp),200 )201 }202 }203 if (entryHasHomographWarning(e)) {204 HomographWarning()205 }206207 208 209 210 Box(Modifier.fillMaxWidth().clipToBounds()) {211 Text(212 text = ghostWatermarkText(e.title),213 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),214 color = peachColors.T0.copy(alpha = 0.03f),215 maxLines = 1,216 softWrap = false,217 overflow = TextOverflow.Clip,218 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),219 )220 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {221 EditableTitleHero(222 title = e.title,223 onSave = { vm.updateField("title", it) },224 )225 }226 }227 Spacer(Modifier.height(PeachSpacing.lg))228229 if (e.isCard()) {230 CardDetailFields(231 entry = e,232 cardRevealed = cardRevealed,233 cvcRevealed = cvcRevealed,234 copiedField = copiedField,235 onToggleCardReveal = { cardRevealed = !cardRevealed },236 onRevealCvc = {237 val activity = context as? FragmentActivity238 if (activity != null && Biometrics.isAvailable(context)) {239 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)240 } else vm.revealCvc()241 },242 onMaskCvc = vm::maskCvc,243 onCopy = vm::copy,244 onSaveCard = vm::updateCard,245 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },246 )247 } else {248 if (!e.isNote()) {249 EditableFieldRow(250 label = "url", field = "url", value = e.url.orEmpty(),251 copied = copiedField == "url",252 onCopy = { vm.copy("url", e.url) },253 onSave = { vm.updateField("url", it) },254 placeholder = "add url",255 )256 }257 e.username?.takeIf { it.isNotBlank() }?.let { v ->258 EditableFieldRow(259 label = "username", field = "username", value = v,260 copied = copiedField == "username",261 onCopy = { vm.copy("username", v) },262 onSave = { vm.updateField("username", it) },263 suggestions = identitySuggestions,264 )265 }266 e.email?.takeIf { it.isNotBlank() }?.let { v ->267 EditableFieldRow(268 label = "email", field = "email", value = v,269 copied = copiedField == "email",270 onCopy = { vm.copy("email", v) },271 onSave = { vm.updateField("email", it) },272 suggestions = identitySuggestions,273 )274 }275 AdditionalUrlRows(276 urls = e.additionalUrls.orEmpty(),277 copiedField = copiedField,278 onCopy = { key, value -> vm.copy(key, value) },279 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },280 onAdd = vm::addAdditionalUrl,281 )282 if (e.hasPasskey()) {283 PasskeyDetailSection(e)284 }285 e.password?.takeIf { it.isNotBlank() }?.let { pw ->286 EditablePasswordRow(287 value = pw,288 copied = copiedField == "password",289 entryTitle = e.title,290 onCopy = { vm.copy("password", pw) },291 onSave = { vm.updateField("password", it) },292 onGenerate = { generatorOpen = true },293 )294 }295 val history = e.passwordHistory.orEmpty()296 if (history.isNotEmpty()) {297 PasswordHistoryToggle(298 count = history.size,299 expanded = historyExpanded,300 onToggle = { historyExpanded = !historyExpanded },301 )302 AnimatedVisibility(visible = historyExpanded) {303 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {304 PasswordHistoryList(305 history = history,306 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },307 onCopy = { oldPw -> vm.copy("password", oldPw) },308 )309 }310 }311 }312 if (e.hasTOTP()) {313 Spacer(Modifier.height(4.dp))314 TotpBandEditable(315 totpField = e.totp!!,316 copied = copiedField == "totp",317 onCopy = vm::copyTotp,318 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },319 )320 }321 }322 e.notes?.takeIf { it.isNotBlank() }?.let { v ->323 EditableFieldRow(324 label = "notes", field = "notes", value = v,325 copied = copiedField == "notes",326 onCopy = { vm.copy("notes", v) },327 onSave = { vm.updateField("notes", it) },328 mono = false, singleLine = false,329 )330 }331 DetailTagRow(332 tags = e.tags.orEmpty(),333 allTags = allTags,334 onAddTag = vm::addTag,335 onRemoveTag = vm::removeTag,336 )337 if (!e.isNote() && !e.isCard()) {338 339 340 341 342 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }343 if (advancedExpanded) {344 CanaryDetailSection(345 entryId = e.id,346 modifiedAt = e.modifiedAt,347 canary = e.canary,348 isPro = isPro,349 message = canaryMessage,350 testing = canaryTesting,351 onSave = vm::saveCanary,352 onTest = vm::testCanary,353 )354 } else {355 Text(356 "+ advanced",357 color = LocalPeachAccent.current,358 style = PeachType.labelMono,359 modifier = Modifier360 .clip(RoundedCornerShape(6.dp))361 .clickable { advancedExpanded = true }362 .padding(vertical = 6.dp),363 )364 }365 }366367 Spacer(Modifier.height(PeachSpacing.xxl))368 }369370 if (e != null) {371 EntryDetailSearchStrip(372 currentIndex = activeEntries.indexOfFirst { it.id == e.id },373 totalEntries = activeEntries.size,374 onOpenList = { onNavigate(PeachNav.VaultList) },375 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },376 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },377 modifier = Modifier.align(Alignment.BottomCenter),378 )379 }380381 if (generatorOpen) {382 GeneratorSheet(383 copied = copiedField == "genDraft",384 onCopy = { draft -> vm.copy("genDraft", draft) },385 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },386 onDismiss = { generatorOpen = false },387 )388 }389 }390 }391}392393@Composable394private fun EntryDetailTopBar(395 entry: com.peachpasswords.vault.VaultEntry?,396 confirmingDelete: Boolean,397 onBack: (() -> Unit)?,398 onLock: () -> Unit,399 onDelete: () -> Unit,400) {401 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }402 PeachTopBar(403 title = when {404 entry == null -> "entry"405 confirmingDelete -> "delete \"${entry.title}\"?"406 domain.isNotEmpty() -> domain407 else -> entry.title408 },409 leading = {410 if (onBack != null) {411 Icon(412 Icons.AutoMirrored.Rounded.ArrowBack,413 contentDescription = "back",414 tint = peachColors.T0,415 modifier = Modifier416 .size(40.dp)417 .clip(CircleShape)418 .clickable(onClick = onBack)419 .padding(PeachSpacing.sm),420 )421 } else {422 423 424 425 426 427 428 429 Icon(430 Icons.Outlined.Lock,431 contentDescription = "Lock vault",432 tint = peachColors.T0,433 modifier = Modifier434 .size(40.dp)435 .clip(CircleShape)436 .clickable(onClick = onLock)437 .padding(11.dp),438 )439 }440 if (entry != null && !confirmingDelete) {441 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)442 }443 },444 actions = {445 if (entry != null) {446 Icon(447 Icons.Outlined.DeleteOutline,448 contentDescription = "delete",449 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,450 modifier = Modifier451 .size(40.dp)452 .clip(CircleShape)453 .clickable(onClick = onDelete)454 .padding(PeachSpacing.sm),455 )456 }457 },458 )459}460461@Composable462private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {463 val accent = LocalPeachAccent.current464 var editing by remember(title) { mutableStateOf(false) }465 var draft by remember(title) { mutableStateOf(title) }466 if (!editing) {467 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {468 CharEnterText(469 text = title,470 style = PeachType.entryNameHero.copy(color = peachColors.T0),471 )472 }473 return474 }475 Column(Modifier.fillMaxWidth()) {476 BasicTextField(477 value = draft,478 onValueChange = { draft = it },479 singleLine = true,480 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),481 cursorBrush = SolidColor(accent),482 modifier = Modifier.fillMaxWidth().drawBehind {483 drawLine(484 color = accent,485 start = Offset(0f, size.height),486 end = Offset(size.width, size.height),487 strokeWidth = 1.dp.toPx(),488 )489 },490 )491 Spacer(Modifier.height(8.dp))492 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {493 Text(494 "✓ save", color = accent, style = PeachType.labelMono,495 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {496 onSave(draft)497 editing = false498 }.padding(vertical = 4.dp),499 )500 Text(501 "✗", color = peachColors.T2, style = PeachType.labelMono,502 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {503 draft = title504 editing = false505 }.padding(horizontal = 6.dp, vertical = 4.dp),506 )507 }508 }509}510511@Composable512private fun HomographWarning() {513 Row(514 Modifier.fillMaxWidth()515 .padding(top = 8.dp)516 .clip(RoundedCornerShape(8.dp))517 .background(peachColors.BreachBg)518 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))519 .padding(horizontal = 12.dp, vertical = 9.dp),520 verticalAlignment = Alignment.CenterVertically,521 ) {522 Text("!", color = peachColors.Breach, style = PeachType.labelMono)523 Spacer(Modifier.width(10.dp))524 Text(525 "This URL contains non-ASCII or punycode characters.",526 color = peachColors.Breach,527 style = PeachType.labelMono,528 modifier = Modifier.weight(1f),529 )530 }531}532533@Composable534private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {535 val passkey = entry.passkey ?: return536 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {537 Column(Modifier.weight(1f)) {538 Text(539 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,540 color = peachColors.T0,541 style = PeachType.body,542 )543 Text(544 listOfNotNull(545 passkey.rpId,546 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,547 ).joinToString(" · "),548 color = peachColors.T2,549 style = PeachType.labelMono,550 )551 Text(552 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",553 color = peachColors.T2,554 style = PeachType.labelMono,555 modifier = Modifier.padding(top = 3.dp),556 )557 }558 }559}560561@Composable562private fun CanaryDetailSection(563 entryId: String,564 modifiedAt: Long,565 canary: com.peachpasswords.vault.Canary?,566 isPro: Boolean,567 message: String?,568 testing: Boolean,569 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,570 onTest: (webhookUrl: String) -> Unit,571) {572 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }573 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }574 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }575 val gated = !isPro && canary == null576 FieldShell("canary alert") {577 Column(Modifier.weight(1f)) {578 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {579 Text(580 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",581 color = peachColors.T0,582 style = PeachType.body,583 modifier = Modifier.weight(1f),584 )585 if (gated) {586 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)587 }588 Text(589 if (enabled) "on" else "off",590 color = if (enabled) LocalPeachAccent.current else peachColors.T2,591 style = PeachType.labelMono,592 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {593 if (gated) {594 onSave(true, webhookUrl, label)595 } else {596 val next = !enabled597 enabled = next598 if (!next) onSave(false, webhookUrl, label)599 }600 }.padding(horizontal = 6.dp, vertical = 4.dp),601 )602 }603 Text(604 "Fires a silent webhook when this entry's password or username is copied.",605 color = peachColors.T2,606 style = PeachType.labelMono,607 modifier = Modifier.padding(top = 3.dp),608 )609 if (enabled) {610 Spacer(Modifier.height(8.dp))611 PeachField(612 label = "webhook url",613 value = webhookUrl,614 onChange = { webhookUrl = it },615 )616 Text(617 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",618 color = peachColors.T2,619 style = PeachType.labelMono,620 )621 PeachField(622 label = "label",623 value = label,624 onChange = { label = it },625 mono = false,626 )627 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {628 Text(629 "save",630 color = LocalPeachAccent.current,631 style = PeachType.labelMono,632 modifier = Modifier.clip(RoundedCornerShape(6.dp))633 .clickable { onSave(true, webhookUrl, label) }634 .padding(vertical = 4.dp),635 )636 Text(637 if (testing) "sending..." else "test ping",638 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,639 style = PeachType.labelMono,640 modifier = Modifier.clip(RoundedCornerShape(6.dp))641 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }642 .padding(vertical = 4.dp),643 )644 }645 }646 message?.let {647 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))648 }649 if (canary?.lastPinged != null || canary?.pingCount != null) {650 Text(651 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),652 color = peachColors.T2,653 style = PeachType.labelMono,654 modifier = Modifier.padding(top = 3.dp),655 )656 }657 }658 }659}660661@Composable662private fun EntryDetailSearchStrip(663 currentIndex: Int,664 totalEntries: Int,665 onOpenList: () -> Unit,666 onPrevious: () -> Unit,667 onNext: () -> Unit,668 modifier: Modifier = Modifier,669) {670 val accent = LocalPeachAccent.current671 val hasEntries = totalEntries > 0 && currentIndex >= 0672 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"673674 Column(675 modifier676 .fillMaxWidth()677 .navigationBarsPadding()678 .padding(horizontal = 16.dp, vertical = 12.dp),679 ) {680 Row(681 Modifier682 .fillMaxWidth()683 .height(56.dp)684 .clip(RoundedCornerShape(16.dp))685 .background(peachColors.S1)686 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))687 .padding(horizontal = 12.dp),688 verticalAlignment = Alignment.CenterVertically,689 ) {690 Row(691 Modifier692 .weight(1f)693 .fillMaxHeight()694 .clickable(onClick = onOpenList)695 .pointerInput(onOpenList) {696 var opened = false697 detectVerticalDragGestures(698 onDragEnd = { opened = false },699 onDragCancel = { opened = false },700 ) { _, dragAmount ->701 if (!opened && dragAmount < -4f) {702 opened = true703 onOpenList()704 }705 }706 },707 verticalAlignment = Alignment.CenterVertically,708 ) {709 Icon(710 Icons.Rounded.Search,711 contentDescription = null,712 tint = accent,713 modifier = Modifier.size(14.dp),714 )715 Spacer(Modifier.width(8.dp))716 Text(717 "search vault...",718 color = peachColors.T2,719 style = PeachType.searchInput,720 modifier = Modifier.weight(1f),721 )722 Text(723 counter,724 color = peachColors.T2,725 style = PeachType.labelMono,726 )727 }728 Spacer(Modifier.width(8.dp))729 Icon(730 Icons.AutoMirrored.Rounded.ArrowBack,731 contentDescription = "Previous entry",732 tint = if (hasEntries) peachColors.T0 else peachColors.T2,733 modifier = Modifier734 .size(38.dp)735 .clip(CircleShape)736 .clickable(enabled = hasEntries, onClick = onPrevious)737 .padding(9.dp),738 )739 Icon(740 Icons.AutoMirrored.Rounded.ArrowForward,741 contentDescription = "Next entry",742 tint = if (hasEntries) peachColors.T0 else peachColors.T2,743 modifier = Modifier744 .size(38.dp)745 .clip(CircleShape)746 .clickable(enabled = hasEntries, onClick = onNext)747 .padding(9.dp),748 )749 }750 }751}752753754@Composable755internal fun FieldShell(756 label: String,757 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,758) {759 val hairline = peachColors.T3760 Column(761 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {762 drawRect(763 color = hairline,764 topLeft = Offset(0f, size.height - 1.dp.toPx()),765 size = Size(size.width, 1.dp.toPx()),766 )767 },768 ) {769 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))770 Spacer(Modifier.height(6.dp))771 Row(verticalAlignment = Alignment.CenterVertically, content = content)772 }773}774775776777778779internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }780781internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =782 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)783784internal fun shortCredential(credentialId: String): String {785 if (credentialId.length <= 12) return credentialId786 return credentialId.take(6) + "..." + credentialId.takeLast(4)787}788789internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {790 val count = pingCount ?: 0791 val countLabel = "$count total alert" + if (count == 1) "" else "s"792 if (lastPinged == null) return countLabel793 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)794 .format(java.util.Date(lastPinged))795 return "Last triggered: $date · $countLabel"796}797798private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {799 val raw = url800 if (!raw.isNullOrBlank()) {801 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')802 if (cleaned.isNotEmpty()) return cleaned803 }804 return ""805}