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.detectVerticalDragGestures15import androidx.compose.foundation.layout.Arrangement16import androidx.compose.foundation.layout.Box17import androidx.compose.foundation.layout.Column18import androidx.compose.foundation.layout.Row19import androidx.compose.foundation.layout.Spacer20import androidx.compose.foundation.layout.fillMaxSize21import androidx.compose.foundation.layout.fillMaxHeight22import androidx.compose.foundation.layout.fillMaxWidth23import androidx.compose.foundation.layout.height24import androidx.compose.foundation.layout.offset25import androidx.compose.foundation.layout.padding26import androidx.compose.foundation.layout.navigationBarsPadding27import androidx.compose.foundation.layout.size28import androidx.compose.foundation.layout.statusBarsPadding29import androidx.compose.foundation.layout.width30import androidx.compose.foundation.rememberScrollState31import androidx.compose.foundation.shape.CircleShape32import androidx.compose.foundation.shape.RoundedCornerShape33import androidx.compose.foundation.text.BasicTextField34import androidx.compose.material.icons.rounded.Check35import androidx.compose.material.icons.rounded.ContentCopy36import androidx.compose.material.icons.rounded.Search37import androidx.compose.foundation.verticalScroll38import androidx.compose.material.icons.Icons39import androidx.compose.material.icons.automirrored.rounded.ArrowBack40import androidx.compose.material.icons.automirrored.rounded.ArrowForward41import androidx.compose.material.icons.outlined.DeleteOutline42import androidx.compose.material.icons.outlined.Lock43import androidx.compose.material3.Icon44import androidx.compose.material3.Text45import androidx.compose.runtime.Composable46import androidx.compose.runtime.getValue47import androidx.compose.runtime.mutableStateOf48import androidx.compose.runtime.produceState49import androidx.compose.runtime.remember50import androidx.compose.runtime.setValue51import androidx.compose.ui.Alignment52import androidx.compose.ui.Modifier53import androidx.compose.ui.draw.clip54import androidx.compose.ui.draw.clipToBounds55import androidx.compose.ui.draw.drawBehind56import androidx.compose.ui.geometry.Offset57import androidx.compose.ui.geometry.Size58import androidx.compose.ui.graphics.Brush59import androidx.compose.ui.graphics.Color60import androidx.compose.ui.graphics.SolidColor61import androidx.compose.ui.graphics.graphicsLayer62import androidx.compose.ui.platform.LocalContext63import androidx.compose.ui.input.pointer.pointerInput64import androidx.compose.ui.text.TextStyle65import androidx.compose.ui.text.font.FontStyle66import androidx.compose.ui.text.style.TextOverflow67import androidx.compose.ui.unit.dp68import androidx.compose.ui.unit.em69import androidx.compose.ui.unit.sp70import androidx.compose.runtime.collectAsState71import androidx.fragment.app.FragmentActivity72import androidx.hilt.navigation.compose.hiltViewModel73import com.peachpasswords.shared.detectHomograph74import com.peachpasswords.ui.PeachNav75import com.peachpasswords.ui.components.CharEnterText76import com.peachpasswords.ui.components.CopyButton77import com.peachpasswords.ui.components.FaviconInitials78import com.peachpasswords.ui.components.PeachEmptyOrEndState79import com.peachpasswords.ui.components.PeachTopBar80import com.peachpasswords.ui.components.SecureFlagEffect81import com.peachpasswords.ui.components.TOTPRing82import com.peachpasswords.ui.lock.Biometrics83import com.peachpasswords.ui.theme.LocalPeachAccent84import com.peachpasswords.ui.theme.LocalReduceMotion85import com.peachpasswords.ui.theme.PeachSpacing86import com.peachpasswords.ui.theme.peachColors87import com.peachpasswords.ui.theme.PeachType88import com.peachpasswords.ui.theme.ShellBackground89import com.peachpasswords.vault.TOTPGenerator90import com.peachpasswords.vault.hasTOTP91import com.peachpasswords.vault.hasPasskey92import com.peachpasswords.vault.isBreach93import com.peachpasswords.vault.isCard94import com.peachpasswords.vault.isNote95import com.peachpasswords.vault.isPasskeyOnly9697@Composable98fun EntryDetailScreen(99 id: String,100 onNavigate: (PeachNav) -> Unit,101 102 103 104 onBack: (() -> Unit)?,105 vm: EntryDetailViewModel = hiltViewModel(),106) {107 val entry by vm.entry.collectAsState()108 val activeEntries by vm.activeEntries.collectAsState()109 val identitySuggestions by vm.identitySuggestions.collectAsState()110 val allTags by vm.allTags.collectAsState()111 val isPro by vm.isPro.collectAsState()112 val cvcRevealed by vm.cvcRevealed.collectAsState()113 var cardRevealed by remember { mutableStateOf(false) }114 val copiedField by vm.copiedField.collectAsState()115 val canaryMessage by vm.canaryMessage.collectAsState()116 val canaryTesting by vm.canaryTesting.collectAsState()117 val context = LocalContext.current118 val accent = LocalPeachAccent.current119 var confirmingDelete by remember { mutableStateOf(false) }120 var generatorOpen by remember { mutableStateOf(false) }121 var historyExpanded by remember { mutableStateOf(false) }122123 SecureFlagEffect()124125 val e = entry126 ShellBackground {127 Box(Modifier.fillMaxSize().statusBarsPadding()) {128 Column(129 Modifier.fillMaxSize()130 .padding(bottom = 100.dp)131 .verticalScroll(rememberScrollState())132 .padding(horizontal = PeachSpacing.xl),133 ) {134 EntryDetailTopBar(135 entry = e,136 confirmingDelete = confirmingDelete,137 onBack = onBack,138 onLock = vm::lock,139 onDelete = {140 if (confirmingDelete) {141 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })142 } else {143 confirmingDelete = true144 }145 },146 )147 Spacer(Modifier.height(PeachSpacing.lg))148149 if (e == null) {150 PeachEmptyOrEndState(151 message = "entry not found",152 detail = "It may have been moved or deleted.",153 modifier = Modifier.padding(top = PeachSpacing.xxl),154 )155 return@Column156 }157158 159 if (e.isBreach()) {160 Row(161 Modifier.fillMaxWidth()162 .background(peachColors.BreachBg)163 .padding(horizontal = 16.dp, vertical = 8.dp),164 verticalAlignment = Alignment.CenterVertically,165 ) {166 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)167 Spacer(Modifier.width(10.dp))168 Text(169 "Password found in a data breach — change it now",170 color = peachColors.Breach, style = PeachType.labelMono,171 modifier = Modifier.weight(1f),172 )173 Text(174 "Change password",175 color = accent, style = PeachType.labelMono,176 modifier = Modifier.clip(RoundedCornerShape(6.dp))177 .clickable { generatorOpen = true }178 .padding(horizontal = 6.dp, vertical = 4.dp),179 )180 }181 }182 if (entryHasHomographWarning(e)) {183 HomographWarning()184 }185186 187 188 189 Box(Modifier.fillMaxWidth().clipToBounds()) {190 Text(191 text = ghostWatermarkText(e.title),192 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),193 color = peachColors.T0.copy(alpha = 0.03f),194 maxLines = 1,195 softWrap = false,196 overflow = TextOverflow.Clip,197 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),198 )199 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {200 EditableTitleHero(201 title = e.title,202 onSave = { vm.updateField("title", it) },203 )204 }205 }206 Spacer(Modifier.height(PeachSpacing.lg))207208 if (e.isCard()) {209 CardDetailFields(210 entry = e,211 cardRevealed = cardRevealed,212 cvcRevealed = cvcRevealed,213 copiedField = copiedField,214 onToggleCardReveal = { cardRevealed = !cardRevealed },215 onRevealCvc = {216 val activity = context as? FragmentActivity217 if (activity != null && Biometrics.isAvailable(context)) {218 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)219 } else vm.revealCvc()220 },221 onMaskCvc = vm::maskCvc,222 onCopy = vm::copy,223 onSaveCard = vm::updateCard,224 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },225 )226 } else {227 if (!e.isNote()) {228 EditableFieldRow(229 label = "url", field = "url", value = e.url.orEmpty(),230 copied = copiedField == "url",231 onCopy = { vm.copy("url", e.url) },232 onSave = { vm.updateField("url", it) },233 placeholder = "add url",234 )235 }236 e.username?.takeIf { it.isNotBlank() }?.let { v ->237 EditableFieldRow(238 label = "username", field = "username", value = v,239 copied = copiedField == "username",240 onCopy = { vm.copy("username", v) },241 onSave = { vm.updateField("username", it) },242 suggestions = identitySuggestions,243 )244 }245 e.email?.takeIf { it.isNotBlank() }?.let { v ->246 EditableFieldRow(247 label = "email", field = "email", value = v,248 copied = copiedField == "email",249 onCopy = { vm.copy("email", v) },250 onSave = { vm.updateField("email", it) },251 suggestions = identitySuggestions,252 )253 }254 AdditionalUrlRows(255 urls = e.additionalUrls.orEmpty(),256 copiedField = copiedField,257 onCopy = { key, value -> vm.copy(key, value) },258 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },259 onAdd = vm::addAdditionalUrl,260 )261 if (e.hasPasskey()) {262 PasskeyDetailSection(e)263 }264 e.password?.takeIf { it.isNotBlank() }?.let { pw ->265 EditablePasswordRow(266 value = pw,267 copied = copiedField == "password",268 entryTitle = e.title,269 onCopy = { vm.copy("password", pw) },270 onSave = { vm.updateField("password", it) },271 onGenerate = { generatorOpen = true },272 )273 }274 val history = e.passwordHistory.orEmpty()275 if (history.isNotEmpty()) {276 PasswordHistoryToggle(277 count = history.size,278 expanded = historyExpanded,279 onToggle = { historyExpanded = !historyExpanded },280 )281 AnimatedVisibility(visible = historyExpanded) {282 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {283 PasswordHistoryList(284 history = history,285 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },286 onCopy = { oldPw -> vm.copy("password", oldPw) },287 )288 }289 }290 }291 if (e.hasTOTP()) {292 Spacer(Modifier.height(4.dp))293 TotpBandEditable(294 totpField = e.totp!!,295 copied = copiedField == "totp",296 onCopy = vm::copyTotp,297 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },298 )299 }300 }301 e.notes?.takeIf { it.isNotBlank() }?.let { v ->302 EditableFieldRow(303 label = "notes", field = "notes", value = v,304 copied = copiedField == "notes",305 onCopy = { vm.copy("notes", v) },306 onSave = { vm.updateField("notes", it) },307 mono = false, singleLine = false,308 )309 }310 DetailTagRow(311 tags = e.tags.orEmpty(),312 allTags = allTags,313 onAddTag = vm::addTag,314 onRemoveTag = vm::removeTag,315 )316 if (!e.isNote() && !e.isCard()) {317 318 319 320 321 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }322 if (advancedExpanded) {323 CanaryDetailSection(324 entryId = e.id,325 modifiedAt = e.modifiedAt,326 canary = e.canary,327 isPro = isPro,328 message = canaryMessage,329 testing = canaryTesting,330 onSave = vm::saveCanary,331 onTest = vm::testCanary,332 )333 } else {334 Text(335 "+ advanced",336 color = LocalPeachAccent.current,337 style = PeachType.labelMono,338 modifier = Modifier339 .clip(RoundedCornerShape(6.dp))340 .clickable { advancedExpanded = true }341 .padding(vertical = 6.dp),342 )343 }344 }345346 Spacer(Modifier.height(PeachSpacing.xxl))347 }348349 if (e != null) {350 EntryDetailSearchStrip(351 currentIndex = activeEntries.indexOfFirst { it.id == e.id },352 totalEntries = activeEntries.size,353 onOpenList = { onNavigate(PeachNav.VaultList) },354 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },355 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },356 modifier = Modifier.align(Alignment.BottomCenter),357 )358 }359360 if (generatorOpen) {361 GeneratorSheet(362 copied = copiedField == "genDraft",363 onCopy = { draft -> vm.copy("genDraft", draft) },364 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },365 onDismiss = { generatorOpen = false },366 )367 }368 }369 }370}371372@Composable373private fun EntryDetailTopBar(374 entry: com.peachpasswords.vault.VaultEntry?,375 confirmingDelete: Boolean,376 onBack: (() -> Unit)?,377 onLock: () -> Unit,378 onDelete: () -> Unit,379) {380 val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }381 PeachTopBar(382 title = when {383 entry == null -> "entry"384 confirmingDelete -> "delete \"${entry.title}\"?"385 domain.isNotEmpty() -> domain386 else -> entry.title387 },388 leading = {389 if (onBack != null) {390 Icon(391 Icons.AutoMirrored.Rounded.ArrowBack,392 contentDescription = "back",393 tint = peachColors.T0,394 modifier = Modifier395 .size(40.dp)396 .clip(CircleShape)397 .clickable(onClick = onBack)398 .padding(PeachSpacing.sm),399 )400 } else {401 402 403 404 405 Icon(406 Icons.Outlined.Lock,407 contentDescription = "Lock vault",408 tint = peachColors.T0,409 modifier = Modifier410 .size(40.dp)411 .clip(CircleShape)412 .clickable(onClick = onLock)413 .padding(PeachSpacing.sm),414 )415 }416 if (entry != null && !confirmingDelete) {417 FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)418 }419 },420 actions = {421 if (entry != null) {422 Icon(423 Icons.Outlined.DeleteOutline,424 contentDescription = "delete",425 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,426 modifier = Modifier427 .size(40.dp)428 .clip(CircleShape)429 .clickable(onClick = onDelete)430 .padding(PeachSpacing.sm),431 )432 }433 },434 )435}436437@Composable438private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {439 val accent = LocalPeachAccent.current440 var editing by remember(title) { mutableStateOf(false) }441 var draft by remember(title) { mutableStateOf(title) }442 if (!editing) {443 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {444 CharEnterText(445 text = title,446 style = PeachType.entryNameHero.copy(color = peachColors.T0),447 )448 }449 return450 }451 Column(Modifier.fillMaxWidth()) {452 BasicTextField(453 value = draft,454 onValueChange = { draft = it },455 singleLine = true,456 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),457 cursorBrush = SolidColor(accent),458 modifier = Modifier.fillMaxWidth().drawBehind {459 drawLine(460 color = accent,461 start = Offset(0f, size.height),462 end = Offset(size.width, size.height),463 strokeWidth = 1.dp.toPx(),464 )465 },466 )467 Spacer(Modifier.height(8.dp))468 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {469 Text(470 "✓ save", color = accent, style = PeachType.labelMono,471 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {472 onSave(draft)473 editing = false474 }.padding(vertical = 4.dp),475 )476 Text(477 "✗", color = peachColors.T2, style = PeachType.labelMono,478 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {479 draft = title480 editing = false481 }.padding(horizontal = 6.dp, vertical = 4.dp),482 )483 }484 }485}486487@Composable488private fun HomographWarning() {489 Row(490 Modifier.fillMaxWidth()491 .padding(top = 8.dp)492 .clip(RoundedCornerShape(8.dp))493 .background(peachColors.BreachBg)494 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))495 .padding(horizontal = 12.dp, vertical = 9.dp),496 verticalAlignment = Alignment.CenterVertically,497 ) {498 Text("!", color = peachColors.Breach, style = PeachType.labelMono)499 Spacer(Modifier.width(10.dp))500 Text(501 "This URL contains non-ASCII or punycode characters.",502 color = peachColors.Breach,503 style = PeachType.labelMono,504 modifier = Modifier.weight(1f),505 )506 }507}508509@Composable510private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {511 val passkey = entry.passkey ?: return512 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {513 Column(Modifier.weight(1f)) {514 Text(515 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,516 color = peachColors.T0,517 style = PeachType.body,518 )519 Text(520 listOfNotNull(521 passkey.rpId,522 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,523 ).joinToString(" · "),524 color = peachColors.T2,525 style = PeachType.labelMono,526 )527 Text(528 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",529 color = peachColors.T2,530 style = PeachType.labelMono,531 modifier = Modifier.padding(top = 3.dp),532 )533 }534 }535}536537@Composable538private fun CanaryDetailSection(539 entryId: String,540 modifiedAt: Long,541 canary: com.peachpasswords.vault.Canary?,542 isPro: Boolean,543 message: String?,544 testing: Boolean,545 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,546 onTest: (webhookUrl: String) -> Unit,547) {548 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }549 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }550 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }551 val gated = !isPro && canary == null552 FieldShell("canary alert") {553 Column(Modifier.weight(1f)) {554 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {555 Text(556 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",557 color = peachColors.T0,558 style = PeachType.body,559 modifier = Modifier.weight(1f),560 )561 if (gated) {562 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)563 }564 Text(565 if (enabled) "on" else "off",566 color = if (enabled) LocalPeachAccent.current else peachColors.T2,567 style = PeachType.labelMono,568 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {569 if (gated) {570 onSave(true, webhookUrl, label)571 } else {572 val next = !enabled573 enabled = next574 if (!next) onSave(false, webhookUrl, label)575 }576 }.padding(horizontal = 6.dp, vertical = 4.dp),577 )578 }579 Text(580 "Fires a silent webhook when this entry's password or username is copied.",581 color = peachColors.T2,582 style = PeachType.labelMono,583 modifier = Modifier.padding(top = 3.dp),584 )585 if (enabled) {586 Spacer(Modifier.height(8.dp))587 PeachField(588 label = "webhook url",589 value = webhookUrl,590 onChange = { webhookUrl = it },591 )592 Text(593 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",594 color = peachColors.T2,595 style = PeachType.labelMono,596 )597 PeachField(598 label = "label",599 value = label,600 onChange = { label = it },601 mono = false,602 )603 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {604 Text(605 "save",606 color = LocalPeachAccent.current,607 style = PeachType.labelMono,608 modifier = Modifier.clip(RoundedCornerShape(6.dp))609 .clickable { onSave(true, webhookUrl, label) }610 .padding(vertical = 4.dp),611 )612 Text(613 if (testing) "sending..." else "test ping",614 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,615 style = PeachType.labelMono,616 modifier = Modifier.clip(RoundedCornerShape(6.dp))617 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }618 .padding(vertical = 4.dp),619 )620 }621 }622 message?.let {623 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))624 }625 if (canary?.lastPinged != null || canary?.pingCount != null) {626 Text(627 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),628 color = peachColors.T2,629 style = PeachType.labelMono,630 modifier = Modifier.padding(top = 3.dp),631 )632 }633 }634 }635}636637@Composable638private fun EntryDetailSearchStrip(639 currentIndex: Int,640 totalEntries: Int,641 onOpenList: () -> Unit,642 onPrevious: () -> Unit,643 onNext: () -> Unit,644 modifier: Modifier = Modifier,645) {646 val accent = LocalPeachAccent.current647 val hasEntries = totalEntries > 0 && currentIndex >= 0648 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"649650 Column(651 modifier652 .fillMaxWidth()653 .navigationBarsPadding()654 .padding(horizontal = 16.dp, vertical = 12.dp),655 ) {656 Row(657 Modifier658 .fillMaxWidth()659 .height(56.dp)660 .clip(RoundedCornerShape(16.dp))661 .background(peachColors.S1)662 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))663 .padding(horizontal = 12.dp),664 verticalAlignment = Alignment.CenterVertically,665 ) {666 Row(667 Modifier668 .weight(1f)669 .fillMaxHeight()670 .clickable(onClick = onOpenList)671 .pointerInput(onOpenList) {672 var opened = false673 detectVerticalDragGestures(674 onDragEnd = { opened = false },675 onDragCancel = { opened = false },676 ) { _, dragAmount ->677 if (!opened && dragAmount < -4f) {678 opened = true679 onOpenList()680 }681 }682 },683 verticalAlignment = Alignment.CenterVertically,684 ) {685 Icon(686 Icons.Rounded.Search,687 contentDescription = null,688 tint = accent,689 modifier = Modifier.size(14.dp),690 )691 Spacer(Modifier.width(8.dp))692 Text(693 "search vault...",694 color = peachColors.T2,695 style = PeachType.searchInput,696 modifier = Modifier.weight(1f),697 )698 Text(699 counter,700 color = peachColors.T2,701 style = PeachType.labelMono,702 )703 }704 Spacer(Modifier.width(8.dp))705 Icon(706 Icons.AutoMirrored.Rounded.ArrowBack,707 contentDescription = "Previous entry",708 tint = if (hasEntries) peachColors.T0 else peachColors.T2,709 modifier = Modifier710 .size(38.dp)711 .clip(CircleShape)712 .clickable(enabled = hasEntries, onClick = onPrevious)713 .padding(9.dp),714 )715 Icon(716 Icons.AutoMirrored.Rounded.ArrowForward,717 contentDescription = "Next entry",718 tint = if (hasEntries) peachColors.T0 else peachColors.T2,719 modifier = Modifier720 .size(38.dp)721 .clip(CircleShape)722 .clickable(enabled = hasEntries, onClick = onNext)723 .padding(9.dp),724 )725 }726 }727}728729730@Composable731internal fun FieldShell(732 label: String,733 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,734) {735 val hairline = peachColors.T3736 Column(737 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {738 drawRect(739 color = hairline,740 topLeft = Offset(0f, size.height - 1.dp.toPx()),741 size = Size(size.width, 1.dp.toPx()),742 )743 },744 ) {745 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))746 Spacer(Modifier.height(6.dp))747 Row(verticalAlignment = Alignment.CenterVertically, content = content)748 }749}750751752753754755internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }756757internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =758 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)759760internal fun shortCredential(credentialId: String): String {761 if (credentialId.length <= 12) return credentialId762 return credentialId.take(6) + "..." + credentialId.takeLast(4)763}764765internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {766 val count = pingCount ?: 0767 val countLabel = "$count total alert" + if (count == 1) "" else "s"768 if (lastPinged == null) return countLabel769 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)770 .format(java.util.Date(lastPinged))771 return "Last triggered: $date · $countLabel"772}773774private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {775 val raw = url776 if (!raw.isNullOrBlank()) {777 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')778 if (cleaned.isNotEmpty()) return cleaned779 }780 return ""781}