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") { onNavigate(PeachNav.VaultList) }151 Spacer(Modifier.height(PeachSpacing.sm))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().height(100.dp).clipToBounds()) {194 Text(195 text = ghostWatermarkText(e.title),196 style = PeachType.ghostMark.copy(fontSize = 150.sp),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.sm))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 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }232 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }233 val hasPassword = !e.password.isNullOrBlank()234 if (!e.isNote()) {235 CredentialStack {236 EditableFieldRow(237 label = "url", field = "url", value = e.url.orEmpty(),238 copied = copiedField == "url",239 onCopy = { vm.copy("url", e.url) },240 onSave = { vm.updateField("url", it) },241 placeholder = "add url",242 onEditingChange = { urlEditing = it },243 stacked = true,244 divider = showUsernameField || showEmailField || hasPassword,245 )246 247 248 249 if (showUsernameField) {250 EditableFieldRow(251 label = "username", field = "username", value = e.username.orEmpty(),252 copied = copiedField == "username",253 onCopy = { vm.copy("username", e.username) },254 onSave = { vm.updateField("username", it) },255 suggestions = identitySuggestions,256 placeholder = "add username",257 stacked = true,258 divider = showEmailField || hasPassword,259 )260 }261 if (showEmailField) {262 EditableFieldRow(263 label = "email", field = "email", value = e.email.orEmpty(),264 copied = copiedField == "email",265 onCopy = { vm.copy("email", e.email) },266 onSave = { vm.updateField("email", it) },267 suggestions = identitySuggestions,268 placeholder = "add email",269 stacked = true,270 divider = hasPassword,271 )272 }273 e.password?.takeIf { it.isNotBlank() }?.let { pw ->274 EditablePasswordRow(275 value = pw,276 copied = copiedField == "password",277 entryTitle = e.title,278 onCopy = { vm.copy("password", pw) },279 onSave = { vm.updateField("password", it) },280 onGenerate = { generatorOpen = true },281 stacked = true,282 )283 }284 }285 }286 if (!showUsernameField || !showEmailField) {287 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {288 if (!showUsernameField) {289 Text(290 "+ add username", color = accent, style = PeachType.labelMono,291 modifier = Modifier292 .clip(RoundedCornerShape(6.dp))293 .clickable { showUsernameField = true }294 .padding(vertical = 6.dp),295 )296 }297 if (!showEmailField) {298 Text(299 "+ add email", color = accent, style = PeachType.labelMono,300 modifier = Modifier301 .clip(RoundedCornerShape(6.dp))302 .clickable { showEmailField = true }303 .padding(vertical = 6.dp),304 )305 }306 }307 }308 AdditionalUrlRows(309 urls = e.additionalUrls.orEmpty(),310 copiedField = copiedField,311 onCopy = { key, value -> vm.copy(key, value) },312 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },313 onAdd = vm::addAdditionalUrl,314 showAdd = urlEditing,315 )316 if (e.hasPasskey()) {317 PasskeyDetailSection(e)318 }319 320 321 322 323 val history = e.passwordHistory.orEmpty()324 PasswordHistoryToggle(325 count = history.size,326 expanded = historyExpanded,327 onToggle = { historyExpanded = !historyExpanded },328 )329 AnimatedVisibility(visible = historyExpanded) {330 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {331 PasswordHistoryList(332 history = history,333 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },334 onCopy = { oldPw -> vm.copy("password", oldPw) },335 )336 }337 }338 if (e.hasTOTP()) {339 Spacer(Modifier.height(4.dp))340 TotpBandEditable(341 totpField = e.totp!!,342 copied = copiedField == "totp",343 onCopy = vm::copyTotp,344 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },345 )346 }347 }348 e.notes?.takeIf { it.isNotBlank() }?.let { v ->349 350 351 val notesAreHtml = isNoteHtml(v)352 EditableFieldRow(353 label = "notes", field = "notes", value = v,354 copied = copiedField == "notes",355 onCopy = { vm.copy("notes", notePlainText(v)) },356 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },357 mono = false, singleLine = false,358 displayText = noteRendered(v),359 draftText = noteEditorText(v),360 )361 }362 EntryDetailActions(363 confirmingDelete = confirmingDelete,364 onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },365 onDelete = {366 if (confirmingDelete) {367 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })368 } else {369 confirmingDelete = true370 }371 },372 )373 Spacer(Modifier.height(PeachSpacing.md))374 DetailTagRow(375 tags = e.tags.orEmpty(),376 allTags = allTags,377 onAddTag = vm::addTag,378 onRemoveTag = vm::removeTag,379 )380 if (!e.isNote() && !e.isCard()) {381 382 383 384 385 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }386 if (advancedExpanded) {387 CanaryDetailSection(388 entryId = e.id,389 modifiedAt = e.modifiedAt,390 canary = e.canary,391 isPro = isPro,392 message = canaryMessage,393 testing = canaryTesting,394 onSave = vm::saveCanary,395 onTest = vm::testCanary,396 )397 } else {398 FieldShell("advanced") {399 Text(400 "Canary alert and protection",401 color = peachColors.T2,402 style = PeachType.bodyMono,403 modifier = Modifier.weight(1f),404 )405 Text(406 "open", color = LocalPeachAccent.current, style = PeachType.labelMono,407 modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),408 )409 }410 }411 }412413 Spacer(Modifier.height(PeachSpacing.xxl))414 }415416 if (e != null) {417 EntryDetailBottomControls(418 currentIndex = activeEntries.indexOfFirst { it.id == e.id },419 totalEntries = activeEntries.size,420 onOpenList = { onNavigate(PeachNav.VaultList) },421 onSearch = { onNavigate(PeachNav.VaultSearch) },422 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },423 onOpenNotes = { onNavigate(PeachNav.Notes) },424 onSettings = { onNavigate(PeachNav.Settings) },425 onLock = vm::lock,426 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },427 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },428 modifier = Modifier.align(Alignment.BottomCenter),429 )430 }431432 if (generatorOpen) {433 GeneratorSheet(434 copied = copiedField == "genDraft",435 onCopy = { draft -> vm.copy("genDraft", draft) },436 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },437 onDismiss = { generatorOpen = false },438 )439 }440 }441 }442}443444@Composable445private fun EntryDetailActions(446 confirmingDelete: Boolean,447 onEdit: () -> Unit,448 onDelete: () -> Unit,449) {450 val accent = LocalPeachAccent.current451 Row(452 Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),453 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),454 ) {455 DetailActionButton(456 label = "edit",457 color = peachColors.Bg,458 background = accent,459 border = accent,460 onClick = onEdit,461 modifier = Modifier.weight(1f),462 )463 DetailActionButton(464 label = if (confirmingDelete) "confirm trash" else "move to trash",465 color = if (confirmingDelete) peachColors.Breach else accent,466 background = Color.Transparent,467 border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,468 onClick = onDelete,469 modifier = Modifier.weight(1f),470 )471 }472}473474@Composable475private fun DetailActionButton(476 label: String,477 color: Color,478 background: Color,479 border: Color,480 onClick: () -> Unit,481 modifier: Modifier = Modifier,482) {483 Box(484 modifier.height(52.dp)485 .clip(RoundedCornerShape(PeachRadius.Button))486 .background(background)487 .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))488 .clickable(onClick = onClick),489 contentAlignment = Alignment.Center,490 ) {491 Text(label, color = color, style = PeachType.bodyMono)492 }493}494495@Composable496private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {497 val accent = LocalPeachAccent.current498 var editing by remember(title) { mutableStateOf(false) }499 var draft by remember(title) { mutableStateOf(title) }500 if (!editing) {501 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {502 CharEnterText(503 text = title,504 style = PeachType.entryNameHero.copy(color = peachColors.T0),505 )506 }507 return508 }509 Column(Modifier.fillMaxWidth()) {510 BasicTextField(511 value = draft,512 onValueChange = { draft = it },513 singleLine = true,514 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),515 cursorBrush = SolidColor(accent),516 modifier = Modifier.fillMaxWidth().drawBehind {517 drawLine(518 color = accent,519 start = Offset(0f, size.height),520 end = Offset(size.width, size.height),521 strokeWidth = 1.dp.toPx(),522 )523 },524 )525 Spacer(Modifier.height(8.dp))526 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {527 Text(528 "✓ save", color = accent, style = PeachType.labelMono,529 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {530 onSave(draft)531 editing = false532 }.padding(vertical = 4.dp),533 )534 Text(535 "✗", color = peachColors.T2, style = PeachType.labelMono,536 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {537 draft = title538 editing = false539 }.padding(horizontal = 6.dp, vertical = 4.dp),540 )541 }542 }543}544545@Composable546private fun HomographWarning() {547 Row(548 Modifier.fillMaxWidth()549 .padding(top = 8.dp)550 .clip(RoundedCornerShape(8.dp))551 .background(peachColors.BreachBg)552 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))553 .padding(horizontal = 12.dp, vertical = 9.dp),554 verticalAlignment = Alignment.CenterVertically,555 ) {556 Text("!", color = peachColors.Breach, style = PeachType.labelMono)557 Spacer(Modifier.width(10.dp))558 Text(559 "This URL contains non-ASCII or punycode characters.",560 color = peachColors.Breach,561 style = PeachType.labelMono,562 modifier = Modifier.weight(1f),563 )564 }565}566567@Composable568private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {569 val passkey = entry.passkey ?: return570 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {571 Column(Modifier.weight(1f)) {572 Text(573 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,574 color = peachColors.T0,575 style = PeachType.body,576 )577 Text(578 listOfNotNull(579 passkey.rpId,580 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,581 ).joinToString(" · "),582 color = peachColors.T2,583 style = PeachType.labelMono,584 )585 Text(586 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",587 color = peachColors.T2,588 style = PeachType.labelMono,589 modifier = Modifier.padding(top = 3.dp),590 )591 }592 }593}594595@Composable596private fun CanaryDetailSection(597 entryId: String,598 modifiedAt: Long,599 canary: com.peachpasswords.vault.Canary?,600 isPro: Boolean,601 message: String?,602 testing: Boolean,603 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,604 onTest: (webhookUrl: String) -> Unit,605) {606 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }607 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }608 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }609 val gated = !isPro && canary == null610 FieldShell("canary alert") {611 Column(Modifier.weight(1f)) {612 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {613 Text(614 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",615 color = peachColors.T0,616 style = PeachType.body,617 modifier = Modifier.weight(1f),618 )619 if (gated) {620 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)621 }622 Text(623 if (enabled) "on" else "off",624 color = if (enabled) LocalPeachAccent.current else peachColors.T2,625 style = PeachType.labelMono,626 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {627 if (gated) {628 onSave(true, webhookUrl, label)629 } else {630 val next = !enabled631 enabled = next632 if (!next) onSave(false, webhookUrl, label)633 }634 }.padding(horizontal = 6.dp, vertical = 4.dp),635 )636 }637 Text(638 "Fires a silent webhook when this entry's password or username is copied.",639 color = peachColors.T2,640 style = PeachType.labelMono,641 modifier = Modifier.padding(top = 3.dp),642 )643 if (enabled) {644 Spacer(Modifier.height(8.dp))645 PeachField(646 label = "webhook url",647 value = webhookUrl,648 onChange = { webhookUrl = it },649 )650 Text(651 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",652 color = peachColors.T2,653 style = PeachType.labelMono,654 )655 PeachField(656 label = "label",657 value = label,658 onChange = { label = it },659 mono = false,660 )661 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {662 Text(663 "save",664 color = LocalPeachAccent.current,665 style = PeachType.labelMono,666 modifier = Modifier.clip(RoundedCornerShape(6.dp))667 .clickable { onSave(true, webhookUrl, label) }668 .padding(vertical = 4.dp),669 )670 Text(671 if (testing) "sending..." else "test ping",672 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,673 style = PeachType.labelMono,674 modifier = Modifier.clip(RoundedCornerShape(6.dp))675 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }676 .padding(vertical = 4.dp),677 )678 }679 }680 message?.let {681 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))682 }683 if (canary?.lastPinged != null || canary?.pingCount != null) {684 Text(685 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),686 color = peachColors.T2,687 style = PeachType.labelMono,688 modifier = Modifier.padding(top = 3.dp),689 )690 }691 }692 }693}694695@Composable696private fun EntryDetailBottomControls(697 currentIndex: Int,698 totalEntries: Int,699 onOpenList: () -> Unit,700 onSearch: () -> Unit,701 onAddEntry: () -> Unit,702 onOpenNotes: () -> Unit,703 onSettings: () -> Unit,704 onLock: () -> Unit,705 onPrevious: () -> Unit,706 onNext: () -> Unit,707 modifier: Modifier = Modifier,708) {709 val accent = LocalPeachAccent.current710 val hasEntries = totalEntries > 0 && currentIndex >= 0711 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"712713 Column(714 modifier715 .fillMaxWidth()716 ) {717 718 719 Box(720 Modifier.fillMaxWidth().height(40.dp)721 .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),722 )723 Column(724 Modifier.fillMaxWidth()725 .background(peachColors.Bg)726 .navigationBarsPadding(),727 ) {728 Row(729 Modifier.fillMaxWidth().height(44.dp),730 horizontalArrangement = Arrangement.Center,731 verticalAlignment = Alignment.CenterVertically,732 ) {733 Icon(734 Icons.AutoMirrored.Rounded.ArrowBack,735 contentDescription = "Previous entry",736 tint = if (hasEntries) peachColors.T0 else peachColors.T2,737 modifier = Modifier738 .size(36.dp)739 .clip(RoundedCornerShape(8.dp))740 .clickable(enabled = hasEntries, onClick = onPrevious)741 .padding(8.dp),742 )743 Spacer(Modifier.width(PeachSpacing.md))744 Text(counter, color = peachColors.T2, style = PeachType.labelMono)745 Spacer(Modifier.width(PeachSpacing.md))746 Spacer(Modifier.width(4.dp))747 Icon(748 Icons.AutoMirrored.Rounded.ArrowForward,749 contentDescription = "Next entry",750 tint = if (hasEntries) peachColors.T0 else peachColors.T2,751 modifier = Modifier752 .size(36.dp)753 .clip(RoundedCornerShape(8.dp))754 .clickable(enabled = hasEntries, onClick = onNext)755 .padding(8.dp),756 )757 }758 PeachDeckBottomBar(759 onSearch = onSearch,760 onAdd = onAddEntry,761 onNotes = onOpenNotes,762 onSettings = onSettings,763 onLock = onLock,764 onSwipeUp = onOpenList,765 )766 }767 }768}769770771@Composable772internal fun FieldShell(773 label: String,774 modifier: Modifier = Modifier,775 stacked: Boolean = false,776 divider: Boolean = false,777 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,778) {779 if (stacked) {780 Column(modifier.fillMaxWidth()) {781 Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {782 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))783 Spacer(Modifier.height(6.dp))784 Row(verticalAlignment = Alignment.CenterVertically, content = content)785 }786 if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))787 }788 return789 }790 Column(791 modifier.fillMaxWidth()792 .padding(vertical = PeachSpacing.xs)793 .clip(RoundedCornerShape(PeachRadius.StackCard))794 .background(peachColors.SurfaceCard)795 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))796 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),797 ) {798 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))799 Spacer(Modifier.height(6.dp))800 Row(verticalAlignment = Alignment.CenterVertically, content = content)801 }802}803804@Composable805private fun CredentialStack(content: @Composable () -> Unit) {806 Column(807 Modifier.fillMaxWidth()808 .padding(vertical = PeachSpacing.xs)809 .clip(RoundedCornerShape(PeachRadius.StackCard))810 .background(peachColors.SurfaceCard)811 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),812 ) { content() }813}814815816817818819internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }820821internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =822 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)823824internal fun shortCredential(credentialId: String): String {825 if (credentialId.length <= 12) return credentialId826 return credentialId.take(6) + "..." + credentialId.takeLast(4)827}828829internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {830 val count = pingCount ?: 0831 val countLabel = "$count total alert" + if (count == 1) "" else "s"832 if (lastPinged == null) return countLabel833 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)834 .format(java.util.Date(lastPinged))835 return "Last triggered: $date · $countLabel"836}