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.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.fillMaxWidth22import androidx.compose.foundation.layout.height23import androidx.compose.foundation.layout.offset24import androidx.compose.foundation.layout.padding25import androidx.compose.foundation.layout.navigationBarsPadding26import androidx.compose.foundation.layout.size27import androidx.compose.foundation.layout.statusBarsPadding28import androidx.compose.foundation.layout.width29import androidx.compose.foundation.rememberScrollState30import androidx.compose.foundation.shape.RoundedCornerShape31import androidx.compose.foundation.text.BasicTextField32import androidx.compose.material.icons.rounded.Check33import androidx.compose.material.icons.rounded.ContentCopy34import androidx.compose.foundation.verticalScroll35import androidx.compose.material.icons.Icons36import androidx.compose.material.icons.automirrored.rounded.ArrowBack37import androidx.compose.material.icons.automirrored.rounded.ArrowForward38import androidx.compose.material3.Icon39import androidx.compose.material3.Text40import androidx.compose.runtime.Composable41import androidx.compose.runtime.getValue42import androidx.compose.runtime.mutableStateOf43import androidx.compose.runtime.produceState44import androidx.compose.runtime.remember45import androidx.compose.runtime.setValue46import androidx.compose.ui.Alignment47import androidx.compose.ui.Modifier48import androidx.compose.ui.draw.clip49import androidx.compose.ui.draw.clipToBounds50import androidx.compose.ui.draw.drawBehind51import androidx.compose.ui.geometry.Offset52import androidx.compose.ui.geometry.Size53import androidx.compose.ui.graphics.Brush54import androidx.compose.ui.graphics.Color55import androidx.compose.ui.graphics.SolidColor56import androidx.compose.ui.graphics.graphicsLayer57import androidx.compose.ui.platform.LocalContext58import androidx.compose.ui.input.pointer.pointerInput59import androidx.compose.ui.text.TextStyle60import androidx.compose.ui.text.font.FontStyle61import androidx.compose.ui.text.style.TextOverflow62import androidx.compose.ui.unit.dp63import androidx.compose.ui.unit.em64import androidx.compose.ui.unit.sp65import androidx.compose.runtime.collectAsState66import androidx.fragment.app.FragmentActivity67import androidx.hilt.navigation.compose.hiltViewModel68import com.peachpasswords.shared.detectHomograph69import com.peachpasswords.ui.PeachNav70import com.peachpasswords.ui.components.CharEnterText71import com.peachpasswords.ui.components.CopyButton72import com.peachpasswords.ui.components.PeachEmptyOrEndState73import com.peachpasswords.ui.components.SecureFlagEffect74import com.peachpasswords.ui.components.TOTPRing75import com.peachpasswords.ui.lock.Biometrics76import com.peachpasswords.ui.theme.LocalPeachAccent77import com.peachpasswords.ui.theme.LocalReduceMotion78import com.peachpasswords.ui.theme.PeachSpacing79import com.peachpasswords.ui.theme.PeachRadius80import com.peachpasswords.ui.theme.peachColors81import com.peachpasswords.ui.theme.PeachType82import com.peachpasswords.ui.theme.ShellBackground83import com.peachpasswords.vault.TOTPGenerator84import com.peachpasswords.vault.hasTOTP85import com.peachpasswords.vault.hasPasskey86import com.peachpasswords.vault.isBreach87import com.peachpasswords.vault.isCard88import com.peachpasswords.vault.isNote89import com.peachpasswords.vault.isPasskeyOnly9091@Composable92fun EntryDetailScreen(93 id: String,94 onNavigate: (PeachNav) -> Unit,95 96 97 98 onBack: (() -> Unit)?,99 modifier: Modifier = Modifier,100 vm: EntryDetailViewModel = hiltViewModel(),101) {102 SecureFlagEffect()103104 val entry by vm.entry.collectAsState()105 val activeEntries by vm.activeEntries.collectAsState()106 val identitySuggestions by vm.identitySuggestions.collectAsState()107 val allTags by vm.allTags.collectAsState()108 val isPro by vm.isPro.collectAsState()109 val cvcRevealed by vm.cvcRevealed.collectAsState()110 var cardRevealed by remember { mutableStateOf(false) }111 val copiedField by vm.copiedField.collectAsState()112 val canaryMessage by vm.canaryMessage.collectAsState()113 val canaryTesting by vm.canaryTesting.collectAsState()114 val context = LocalContext.current115 val accent = LocalPeachAccent.current116 var confirmingDelete by remember { mutableStateOf(false) }117 var generatorOpen by remember { mutableStateOf(false) }118 var historyExpanded by remember { mutableStateOf(false) }119 var urlEditing by remember { mutableStateOf(false) }120121 val e = entry122 ShellBackground(modifier) {123 Box(124 Modifier.fillMaxSize().statusBarsPadding()125 126 127 128 129 .pointerInput(vm) {130 var dragAccum = 0f131 detectHorizontalDragGestures(132 onDragStart = { dragAccum = 0f },133 onDragEnd = {134 when {135 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)136 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)137 }138 dragAccum = 0f139 },140 onDragCancel = { dragAccum = 0f },141 ) { _, dragAmount -> dragAccum += dragAmount }142 },143 ) {144 Column(145 Modifier.fillMaxSize()146 .padding(bottom = 172.dp)147 .verticalScroll(rememberScrollState())148 .padding(horizontal = PeachSpacing.xl),149 ) {150 PeachVaultIdentity("vault")151 Spacer(Modifier.height(PeachSpacing.xxl))152153 if (e == null) {154 PeachEmptyOrEndState(155 message = "entry not found",156 detail = "It may have been moved or deleted.",157 modifier = Modifier.padding(top = PeachSpacing.xxl),158 )159 return@Column160 }161162 163 if (e.isBreach()) {164 Row(165 Modifier.fillMaxWidth()166 .background(peachColors.BreachBg)167 .padding(horizontal = 16.dp, vertical = 8.dp),168 verticalAlignment = Alignment.CenterVertically,169 ) {170 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)171 Spacer(Modifier.width(10.dp))172 Text(173 "Password found in a data breach — change it now",174 color = peachColors.Breach, style = PeachType.labelMono,175 modifier = Modifier.weight(1f),176 )177 Text(178 "Change password",179 color = accent, style = PeachType.labelMono,180 modifier = Modifier.clip(RoundedCornerShape(6.dp))181 .clickable { generatorOpen = true }182 .padding(horizontal = 6.dp, vertical = 4.dp),183 )184 }185 }186 if (entryHasHomographWarning(e)) {187 HomographWarning()188 }189190 191 192 193 Box(Modifier.fillMaxWidth().clipToBounds()) {194 Text(195 text = ghostWatermarkText(e.title),196 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),197 color = peachColors.T0.copy(alpha = 0.03f),198 maxLines = 1,199 softWrap = false,200 overflow = TextOverflow.Clip,201 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),202 )203 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {204 EditableTitleHero(205 title = e.title,206 onSave = { vm.updateField("title", it) },207 )208 }209 }210 Spacer(Modifier.height(PeachSpacing.lg))211212 if (e.isCard()) {213 CardDetailFields(214 entry = e,215 cardRevealed = cardRevealed,216 cvcRevealed = cvcRevealed,217 copiedField = copiedField,218 onToggleCardReveal = { cardRevealed = !cardRevealed },219 onRevealCvc = {220 val activity = context as? FragmentActivity221 if (activity != null && Biometrics.isAvailable(context)) {222 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)223 } else vm.revealCvc()224 },225 onMaskCvc = vm::maskCvc,226 onCopy = vm::copy,227 onSaveCard = vm::updateCard,228 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },229 )230 } else {231 if (!e.isNote()) {232 EditableFieldRow(233 label = "url", field = "url", value = e.url.orEmpty(),234 copied = copiedField == "url",235 onCopy = { vm.copy("url", e.url) },236 onSave = { vm.updateField("url", it) },237 placeholder = "add url",238 onEditingChange = { urlEditing = it },239 )240 }241 242 243 244 245 246 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }247 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }248 if (showUsernameField) {249 EditableFieldRow(250 label = "username", field = "username", value = e.username.orEmpty(),251 copied = copiedField == "username",252 onCopy = { vm.copy("username", e.username) },253 onSave = { vm.updateField("username", it) },254 suggestions = identitySuggestions,255 placeholder = "add username",256 )257 }258 if (showEmailField) {259 EditableFieldRow(260 label = "email", field = "email", value = e.email.orEmpty(),261 copied = copiedField == "email",262 onCopy = { vm.copy("email", e.email) },263 onSave = { vm.updateField("email", it) },264 suggestions = identitySuggestions,265 placeholder = "add email",266 )267 }268 if (!showUsernameField || !showEmailField) {269 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {270 if (!showUsernameField) {271 Text(272 "+ add username", color = accent, style = PeachType.labelMono,273 modifier = Modifier274 .clip(RoundedCornerShape(6.dp))275 .clickable { showUsernameField = true }276 .padding(vertical = 6.dp),277 )278 }279 if (!showEmailField) {280 Text(281 "+ add email", color = accent, style = PeachType.labelMono,282 modifier = Modifier283 .clip(RoundedCornerShape(6.dp))284 .clickable { showEmailField = true }285 .padding(vertical = 6.dp),286 )287 }288 }289 }290 AdditionalUrlRows(291 urls = e.additionalUrls.orEmpty(),292 copiedField = copiedField,293 onCopy = { key, value -> vm.copy(key, value) },294 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },295 onAdd = vm::addAdditionalUrl,296 showAdd = urlEditing,297 )298 if (e.hasPasskey()) {299 PasskeyDetailSection(e)300 }301 e.password?.takeIf { it.isNotBlank() }?.let { pw ->302 EditablePasswordRow(303 value = pw,304 copied = copiedField == "password",305 entryTitle = e.title,306 onCopy = { vm.copy("password", pw) },307 onSave = { vm.updateField("password", it) },308 onGenerate = { generatorOpen = true },309 )310 }311 312 313 314 315 val history = e.passwordHistory.orEmpty()316 PasswordHistoryToggle(317 count = history.size,318 expanded = historyExpanded,319 onToggle = { historyExpanded = !historyExpanded },320 )321 AnimatedVisibility(visible = historyExpanded) {322 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {323 PasswordHistoryList(324 history = history,325 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },326 onCopy = { oldPw -> vm.copy("password", oldPw) },327 )328 }329 }330 if (e.hasTOTP()) {331 Spacer(Modifier.height(4.dp))332 TotpBandEditable(333 totpField = e.totp!!,334 copied = copiedField == "totp",335 onCopy = vm::copyTotp,336 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },337 )338 }339 }340 e.notes?.takeIf { it.isNotBlank() }?.let { v ->341 342 343 val notesAreHtml = isNoteHtml(v)344 EditableFieldRow(345 label = "notes", field = "notes", value = v,346 copied = copiedField == "notes",347 onCopy = { vm.copy("notes", notePlainText(v)) },348 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },349 mono = false, singleLine = false,350 displayText = noteRendered(v),351 draftText = noteEditorText(v),352 )353 }354 EntryDetailActions(355 confirmingDelete = confirmingDelete,356 onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },357 onDelete = {358 if (confirmingDelete) {359 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })360 } else {361 confirmingDelete = true362 }363 },364 )365 Spacer(Modifier.height(PeachSpacing.md))366 DetailTagRow(367 tags = e.tags.orEmpty(),368 allTags = allTags,369 onAddTag = vm::addTag,370 onRemoveTag = vm::removeTag,371 )372 if (!e.isNote() && !e.isCard()) {373 374 375 376 377 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }378 if (advancedExpanded) {379 CanaryDetailSection(380 entryId = e.id,381 modifiedAt = e.modifiedAt,382 canary = e.canary,383 isPro = isPro,384 message = canaryMessage,385 testing = canaryTesting,386 onSave = vm::saveCanary,387 onTest = vm::testCanary,388 )389 } else {390 FieldShell("advanced") {391 Text(392 "Canary alert and protection",393 color = peachColors.T2,394 style = PeachType.bodyMono,395 modifier = Modifier.weight(1f),396 )397 Text(398 "open", color = LocalPeachAccent.current, style = PeachType.labelMono,399 modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),400 )401 }402 }403 }404405 Spacer(Modifier.height(PeachSpacing.xxl))406 }407408 if (e != null) {409 EntryDetailBottomControls(410 currentIndex = activeEntries.indexOfFirst { it.id == e.id },411 totalEntries = activeEntries.size,412 onOpenList = { onNavigate(PeachNav.VaultList) },413 onSearch = { onNavigate(PeachNav.VaultSearch) },414 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },415 onOpenNotes = { onNavigate(PeachNav.Notes) },416 onSettings = { onNavigate(PeachNav.Settings) },417 onLock = vm::lock,418 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },419 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },420 modifier = Modifier.align(Alignment.BottomCenter),421 )422 }423424 if (generatorOpen) {425 GeneratorSheet(426 copied = copiedField == "genDraft",427 onCopy = { draft -> vm.copy("genDraft", draft) },428 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },429 onDismiss = { generatorOpen = false },430 )431 }432 }433 }434}435436@Composable437private fun EntryDetailActions(438 confirmingDelete: Boolean,439 onEdit: () -> Unit,440 onDelete: () -> Unit,441) {442 val accent = LocalPeachAccent.current443 Row(444 Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),445 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),446 ) {447 DetailActionButton(448 label = "edit",449 color = peachColors.Bg,450 background = accent,451 border = accent,452 onClick = onEdit,453 modifier = Modifier.weight(1f),454 )455 DetailActionButton(456 label = if (confirmingDelete) "confirm trash" else "move to trash",457 color = if (confirmingDelete) peachColors.Breach else accent,458 background = Color.Transparent,459 border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,460 onClick = onDelete,461 modifier = Modifier.weight(1f),462 )463 }464}465466@Composable467private fun DetailActionButton(468 label: String,469 color: Color,470 background: Color,471 border: Color,472 onClick: () -> Unit,473 modifier: Modifier = Modifier,474) {475 Box(476 modifier.height(52.dp)477 .clip(RoundedCornerShape(PeachRadius.Button))478 .background(background)479 .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))480 .clickable(onClick = onClick),481 contentAlignment = Alignment.Center,482 ) {483 Text(label, color = color, style = PeachType.bodyMono)484 }485}486487@Composable488private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {489 val accent = LocalPeachAccent.current490 var editing by remember(title) { mutableStateOf(false) }491 var draft by remember(title) { mutableStateOf(title) }492 if (!editing) {493 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {494 CharEnterText(495 text = title,496 style = PeachType.entryNameHero.copy(color = peachColors.T0),497 )498 }499 return500 }501 Column(Modifier.fillMaxWidth()) {502 BasicTextField(503 value = draft,504 onValueChange = { draft = it },505 singleLine = true,506 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),507 cursorBrush = SolidColor(accent),508 modifier = Modifier.fillMaxWidth().drawBehind {509 drawLine(510 color = accent,511 start = Offset(0f, size.height),512 end = Offset(size.width, size.height),513 strokeWidth = 1.dp.toPx(),514 )515 },516 )517 Spacer(Modifier.height(8.dp))518 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {519 Text(520 "✓ save", color = accent, style = PeachType.labelMono,521 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {522 onSave(draft)523 editing = false524 }.padding(vertical = 4.dp),525 )526 Text(527 "✗", color = peachColors.T2, style = PeachType.labelMono,528 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {529 draft = title530 editing = false531 }.padding(horizontal = 6.dp, vertical = 4.dp),532 )533 }534 }535}536537@Composable538private fun HomographWarning() {539 Row(540 Modifier.fillMaxWidth()541 .padding(top = 8.dp)542 .clip(RoundedCornerShape(8.dp))543 .background(peachColors.BreachBg)544 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))545 .padding(horizontal = 12.dp, vertical = 9.dp),546 verticalAlignment = Alignment.CenterVertically,547 ) {548 Text("!", color = peachColors.Breach, style = PeachType.labelMono)549 Spacer(Modifier.width(10.dp))550 Text(551 "This URL contains non-ASCII or punycode characters.",552 color = peachColors.Breach,553 style = PeachType.labelMono,554 modifier = Modifier.weight(1f),555 )556 }557}558559@Composable560private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {561 val passkey = entry.passkey ?: return562 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {563 Column(Modifier.weight(1f)) {564 Text(565 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,566 color = peachColors.T0,567 style = PeachType.body,568 )569 Text(570 listOfNotNull(571 passkey.rpId,572 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,573 ).joinToString(" · "),574 color = peachColors.T2,575 style = PeachType.labelMono,576 )577 Text(578 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",579 color = peachColors.T2,580 style = PeachType.labelMono,581 modifier = Modifier.padding(top = 3.dp),582 )583 }584 }585}586587@Composable588private fun CanaryDetailSection(589 entryId: String,590 modifiedAt: Long,591 canary: com.peachpasswords.vault.Canary?,592 isPro: Boolean,593 message: String?,594 testing: Boolean,595 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,596 onTest: (webhookUrl: String) -> Unit,597) {598 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }599 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }600 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }601 val gated = !isPro && canary == null602 FieldShell("canary alert") {603 Column(Modifier.weight(1f)) {604 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {605 Text(606 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",607 color = peachColors.T0,608 style = PeachType.body,609 modifier = Modifier.weight(1f),610 )611 if (gated) {612 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)613 }614 Text(615 if (enabled) "on" else "off",616 color = if (enabled) LocalPeachAccent.current else peachColors.T2,617 style = PeachType.labelMono,618 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {619 if (gated) {620 onSave(true, webhookUrl, label)621 } else {622 val next = !enabled623 enabled = next624 if (!next) onSave(false, webhookUrl, label)625 }626 }.padding(horizontal = 6.dp, vertical = 4.dp),627 )628 }629 Text(630 "Fires a silent webhook when this entry's password or username is copied.",631 color = peachColors.T2,632 style = PeachType.labelMono,633 modifier = Modifier.padding(top = 3.dp),634 )635 if (enabled) {636 Spacer(Modifier.height(8.dp))637 PeachField(638 label = "webhook url",639 value = webhookUrl,640 onChange = { webhookUrl = it },641 )642 Text(643 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",644 color = peachColors.T2,645 style = PeachType.labelMono,646 )647 PeachField(648 label = "label",649 value = label,650 onChange = { label = it },651 mono = false,652 )653 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {654 Text(655 "save",656 color = LocalPeachAccent.current,657 style = PeachType.labelMono,658 modifier = Modifier.clip(RoundedCornerShape(6.dp))659 .clickable { onSave(true, webhookUrl, label) }660 .padding(vertical = 4.dp),661 )662 Text(663 if (testing) "sending..." else "test ping",664 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,665 style = PeachType.labelMono,666 modifier = Modifier.clip(RoundedCornerShape(6.dp))667 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }668 .padding(vertical = 4.dp),669 )670 }671 }672 message?.let {673 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))674 }675 if (canary?.lastPinged != null || canary?.pingCount != null) {676 Text(677 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),678 color = peachColors.T2,679 style = PeachType.labelMono,680 modifier = Modifier.padding(top = 3.dp),681 )682 }683 }684 }685}686687@Composable688private fun EntryDetailBottomControls(689 currentIndex: Int,690 totalEntries: Int,691 onOpenList: () -> Unit,692 onSearch: () -> Unit,693 onAddEntry: () -> Unit,694 onOpenNotes: () -> Unit,695 onSettings: () -> Unit,696 onLock: () -> Unit,697 onPrevious: () -> Unit,698 onNext: () -> Unit,699 modifier: Modifier = Modifier,700) {701 val accent = LocalPeachAccent.current702 val hasEntries = totalEntries > 0 && currentIndex >= 0703 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"704705 Column(706 modifier707 .fillMaxWidth()708 ) {709 710 711 Box(712 Modifier.fillMaxWidth().height(40.dp)713 .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),714 )715 Column(716 Modifier.fillMaxWidth()717 .background(peachColors.Bg)718 .navigationBarsPadding(),719 ) {720 Row(721 Modifier.fillMaxWidth().height(44.dp),722 horizontalArrangement = Arrangement.Center,723 verticalAlignment = Alignment.CenterVertically,724 ) {725 Icon(726 Icons.AutoMirrored.Rounded.ArrowBack,727 contentDescription = "Previous entry",728 tint = if (hasEntries) peachColors.T0 else peachColors.T2,729 modifier = Modifier730 .size(36.dp)731 .clip(RoundedCornerShape(8.dp))732 .clickable(enabled = hasEntries, onClick = onPrevious)733 .padding(8.dp),734 )735 Spacer(Modifier.width(PeachSpacing.md))736 Text(counter, color = peachColors.T2, style = PeachType.labelMono)737 Spacer(Modifier.width(PeachSpacing.md))738 Spacer(Modifier.width(4.dp))739 Icon(740 Icons.AutoMirrored.Rounded.ArrowForward,741 contentDescription = "Next entry",742 tint = if (hasEntries) peachColors.T0 else peachColors.T2,743 modifier = Modifier744 .size(36.dp)745 .clip(RoundedCornerShape(8.dp))746 .clickable(enabled = hasEntries, onClick = onNext)747 .padding(8.dp),748 )749 }750 PeachDeckBottomBar(751 onSearch = onSearch,752 onAdd = onAddEntry,753 onNotes = onOpenNotes,754 onSettings = onSettings,755 onLock = onLock,756 onSwipeUp = onOpenList,757 )758 }759 }760}761762763@Composable764internal fun FieldShell(765 label: String,766 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,767) {768 Column(769 Modifier.fillMaxWidth()770 .padding(vertical = PeachSpacing.xs)771 .clip(RoundedCornerShape(PeachRadius.StackCard))772 .background(peachColors.SurfaceCard)773 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))774 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),775 ) {776 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))777 Spacer(Modifier.height(6.dp))778 Row(verticalAlignment = Alignment.CenterVertically, content = content)779 }780}781782783784785786internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }787788internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =789 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)790791internal fun shortCredential(credentialId: String): String {792 if (credentialId.length <= 12) return credentialId793 return credentialId.take(6) + "..." + credentialId.takeLast(4)794}795796internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {797 val count = pingCount ?: 0798 val countLabel = "$count total alert" + if (count == 1) "" else "s"799 if (lastPinged == null) return countLabel800 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)801 .format(java.util.Date(lastPinged))802 return "Last triggered: $date · $countLabel"803}