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.layout.wrapContentHeight30import androidx.compose.foundation.rememberScrollState31import androidx.compose.foundation.shape.RoundedCornerShape32import androidx.compose.foundation.text.BasicTextField33import androidx.compose.material.icons.rounded.Check34import androidx.compose.material.icons.rounded.ContentCopy35import androidx.compose.foundation.verticalScroll36import androidx.compose.material.icons.Icons37import androidx.compose.material.icons.automirrored.rounded.ArrowBack38import androidx.compose.material.icons.automirrored.rounded.ArrowForward39import androidx.compose.material3.Icon40import androidx.compose.material3.Text41import androidx.compose.runtime.Composable42import androidx.compose.runtime.getValue43import androidx.compose.runtime.mutableStateOf44import androidx.compose.runtime.produceState45import androidx.compose.runtime.remember46import androidx.compose.runtime.setValue47import androidx.compose.ui.Alignment48import androidx.compose.ui.Modifier49import androidx.compose.ui.draw.clip50import 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 .verticalScroll(rememberScrollState())147 .padding(horizontal = PeachSpacing.xl)148 .padding(bottom = 172.dp),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(124.dp)) {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 = Modifier202 .align(Alignment.TopCenter)203 .wrapContentHeight(unbounded = true)204 .offset(y = (-16).dp),205 )206 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {207 EditableTitleHero(208 title = e.title,209 onSave = { vm.updateField("title", it) },210 )211 }212 }213 Spacer(Modifier.height(PeachSpacing.sm))214215 if (e.isCard()) {216 CardDetailFields(217 entry = e,218 cardRevealed = cardRevealed,219 cvcRevealed = cvcRevealed,220 copiedField = copiedField,221 onToggleCardReveal = { cardRevealed = !cardRevealed },222 onRevealCvc = {223 val activity = context as? FragmentActivity224 if (activity != null && Biometrics.isAvailable(context)) {225 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)226 } else vm.revealCvc()227 },228 onMaskCvc = vm::maskCvc,229 onCopy = vm::copy,230 onSaveCard = vm::updateCard,231 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },232 )233 } else {234 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }235 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }236 val hasPassword = !e.password.isNullOrBlank()237 if (!e.isNote()) {238 CredentialStack {239 EditableFieldRow(240 label = "url", field = "url", value = e.url.orEmpty(),241 copied = copiedField == "url",242 onCopy = { vm.copy("url", e.url) },243 onSave = { vm.updateField("url", it) },244 placeholder = "add url",245 onEditingChange = { urlEditing = it },246 stacked = true,247 divider = showUsernameField || showEmailField || hasPassword,248 )249 250 251 252 if (showUsernameField) {253 EditableFieldRow(254 label = "username", field = "username", value = e.username.orEmpty(),255 copied = copiedField == "username",256 onCopy = { vm.copy("username", e.username) },257 onSave = { vm.updateField("username", it) },258 suggestions = identitySuggestions,259 placeholder = "add username",260 stacked = true,261 divider = showEmailField || hasPassword,262 )263 }264 if (showEmailField) {265 EditableFieldRow(266 label = "email", field = "email", value = e.email.orEmpty(),267 copied = copiedField == "email",268 onCopy = { vm.copy("email", e.email) },269 onSave = { vm.updateField("email", it) },270 suggestions = identitySuggestions,271 placeholder = "add email",272 stacked = true,273 divider = hasPassword,274 )275 }276 e.password?.takeIf { it.isNotBlank() }?.let { pw ->277 EditablePasswordRow(278 value = pw,279 copied = copiedField == "password",280 entryTitle = e.title,281 onCopy = { vm.copy("password", pw) },282 onSave = { vm.updateField("password", it) },283 onGenerate = { generatorOpen = true },284 stacked = true,285 )286 }287 }288 }289 if (!showUsernameField || !showEmailField) {290 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {291 if (!showUsernameField) {292 Text(293 "+ add username", color = accent, style = PeachType.labelMono,294 modifier = Modifier295 .clip(RoundedCornerShape(6.dp))296 .clickable { showUsernameField = true }297 .padding(vertical = 6.dp),298 )299 }300 if (!showEmailField) {301 Text(302 "+ add email", color = accent, style = PeachType.labelMono,303 modifier = Modifier304 .clip(RoundedCornerShape(6.dp))305 .clickable { showEmailField = true }306 .padding(vertical = 6.dp),307 )308 }309 }310 }311 AdditionalUrlRows(312 urls = e.additionalUrls.orEmpty(),313 copiedField = copiedField,314 onCopy = { key, value -> vm.copy(key, value) },315 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },316 onAdd = vm::addAdditionalUrl,317 showAdd = urlEditing,318 )319 if (e.hasPasskey()) {320 PasskeyDetailSection(e)321 }322 323 324 325 326 val history = e.passwordHistory.orEmpty()327 PasswordHistoryToggle(328 count = history.size,329 expanded = historyExpanded,330 onToggle = { historyExpanded = !historyExpanded },331 )332 AnimatedVisibility(visible = historyExpanded) {333 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {334 PasswordHistoryList(335 history = history,336 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },337 onCopy = { oldPw -> vm.copy("password", oldPw) },338 )339 }340 }341 if (e.hasTOTP()) {342 Spacer(Modifier.height(4.dp))343 TotpBandEditable(344 totpField = e.totp!!,345 copied = copiedField == "totp",346 onCopy = vm::copyTotp,347 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },348 )349 }350 }351 e.notes?.takeIf { it.isNotBlank() }?.let { v ->352 353 354 val notesAreHtml = isNoteHtml(v)355 EditableFieldRow(356 label = "notes", field = "notes", value = v,357 copied = copiedField == "notes",358 onCopy = { vm.copy("notes", notePlainText(v)) },359 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },360 mono = false, singleLine = false,361 displayText = noteRendered(v),362 draftText = noteEditorText(v),363 )364 }365 EntryDetailActions(366 confirmingDelete = confirmingDelete,367 onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },368 onDelete = {369 if (confirmingDelete) {370 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })371 } else {372 confirmingDelete = true373 }374 },375 )376 Spacer(Modifier.height(PeachSpacing.xxl + PeachSpacing.xxl))377 DetailTagRow(378 tags = e.tags.orEmpty(),379 allTags = allTags,380 onAddTag = vm::addTag,381 onRemoveTag = vm::removeTag,382 )383 if (!e.isNote() && !e.isCard()) {384 385 386 387 388 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }389 if (advancedExpanded) {390 CanaryDetailSection(391 entryId = e.id,392 modifiedAt = e.modifiedAt,393 canary = e.canary,394 isPro = isPro,395 message = canaryMessage,396 testing = canaryTesting,397 onSave = vm::saveCanary,398 onTest = vm::testCanary,399 )400 } else {401 FieldShell("advanced") {402 Text(403 "Canary alert and protection",404 color = peachColors.T2,405 style = PeachType.bodyMono,406 modifier = Modifier.weight(1f),407 )408 Text(409 "open", color = LocalPeachAccent.current, style = PeachType.labelMono,410 modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),411 )412 }413 }414 }415416 Spacer(Modifier.height(PeachSpacing.xxl))417 }418419 if (e != null) {420 EntryDetailBottomControls(421 currentIndex = activeEntries.indexOfFirst { it.id == e.id },422 totalEntries = activeEntries.size,423 onOpenList = { onNavigate(PeachNav.VaultList) },424 onSearch = { onNavigate(PeachNav.VaultSearch) },425 onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },426 onOpenNotes = { onNavigate(PeachNav.Notes) },427 onSettings = { onNavigate(PeachNav.Settings) },428 onLock = vm::lock,429 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },430 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },431 modifier = Modifier.align(Alignment.BottomCenter),432 )433 }434435 if (generatorOpen) {436 GeneratorSheet(437 copied = copiedField == "genDraft",438 onCopy = { draft -> vm.copy("genDraft", draft) },439 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },440 onDismiss = { generatorOpen = false },441 )442 }443 }444 }445}446447@Composable448private fun EntryDetailActions(449 confirmingDelete: Boolean,450 onEdit: () -> Unit,451 onDelete: () -> Unit,452) {453 val accent = LocalPeachAccent.current454 Row(455 Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),456 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),457 ) {458 DetailActionButton(459 label = "edit",460 color = peachColors.Bg,461 background = accent,462 border = accent,463 onClick = onEdit,464 modifier = Modifier.weight(1f),465 )466 DetailActionButton(467 label = if (confirmingDelete) "confirm trash" else "move to trash",468 color = if (confirmingDelete) peachColors.Breach else accent,469 background = Color.Transparent,470 border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,471 onClick = onDelete,472 modifier = Modifier.weight(1f),473 )474 }475}476477@Composable478private fun DetailActionButton(479 label: String,480 color: Color,481 background: Color,482 border: Color,483 onClick: () -> Unit,484 modifier: Modifier = Modifier,485) {486 Box(487 modifier.height(52.dp)488 .clip(RoundedCornerShape(PeachRadius.Button))489 .background(background)490 .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))491 .clickable(onClick = onClick),492 contentAlignment = Alignment.Center,493 ) {494 Text(label, color = color, style = PeachType.bodyMono)495 }496}497498@Composable499private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {500 val accent = LocalPeachAccent.current501 var editing by remember(title) { mutableStateOf(false) }502 var draft by remember(title) { mutableStateOf(title) }503 if (!editing) {504 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {505 CharEnterText(506 text = title,507 style = PeachType.entryNameHero.copy(color = peachColors.T0),508 )509 }510 return511 }512 Column(Modifier.fillMaxWidth()) {513 BasicTextField(514 value = draft,515 onValueChange = { draft = it },516 singleLine = true,517 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),518 cursorBrush = SolidColor(accent),519 modifier = Modifier.fillMaxWidth().drawBehind {520 drawLine(521 color = accent,522 start = Offset(0f, size.height),523 end = Offset(size.width, size.height),524 strokeWidth = 1.dp.toPx(),525 )526 },527 )528 Spacer(Modifier.height(8.dp))529 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {530 Text(531 "✓ save", color = accent, style = PeachType.labelMono,532 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {533 onSave(draft)534 editing = false535 }.padding(vertical = 4.dp),536 )537 Text(538 "✗", color = peachColors.T2, style = PeachType.labelMono,539 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {540 draft = title541 editing = false542 }.padding(horizontal = 6.dp, vertical = 4.dp),543 )544 }545 }546}547548@Composable549private fun HomographWarning() {550 Row(551 Modifier.fillMaxWidth()552 .padding(top = 8.dp)553 .clip(RoundedCornerShape(8.dp))554 .background(peachColors.BreachBg)555 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))556 .padding(horizontal = 12.dp, vertical = 9.dp),557 verticalAlignment = Alignment.CenterVertically,558 ) {559 Text("!", color = peachColors.Breach, style = PeachType.labelMono)560 Spacer(Modifier.width(10.dp))561 Text(562 "This URL contains non-ASCII or punycode characters.",563 color = peachColors.Breach,564 style = PeachType.labelMono,565 modifier = Modifier.weight(1f),566 )567 }568}569570@Composable571private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {572 val passkey = entry.passkey ?: return573 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {574 Column(Modifier.weight(1f)) {575 Text(576 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,577 color = peachColors.T0,578 style = PeachType.body,579 )580 Text(581 listOfNotNull(582 passkey.rpId,583 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,584 ).joinToString(" · "),585 color = peachColors.T2,586 style = PeachType.labelMono,587 )588 Text(589 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",590 color = peachColors.T2,591 style = PeachType.labelMono,592 modifier = Modifier.padding(top = 3.dp),593 )594 }595 }596}597598@Composable599private fun CanaryDetailSection(600 entryId: String,601 modifiedAt: Long,602 canary: com.peachpasswords.vault.Canary?,603 isPro: Boolean,604 message: String?,605 testing: Boolean,606 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,607 onTest: (webhookUrl: String) -> Unit,608) {609 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }610 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }611 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }612 val gated = !isPro && canary == null613 FieldShell("canary alert") {614 Column(Modifier.weight(1f)) {615 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {616 Text(617 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",618 color = peachColors.T0,619 style = PeachType.body,620 modifier = Modifier.weight(1f),621 )622 if (gated) {623 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)624 }625 Text(626 if (enabled) "on" else "off",627 color = if (enabled) LocalPeachAccent.current else peachColors.T2,628 style = PeachType.labelMono,629 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {630 if (gated) {631 onSave(true, webhookUrl, label)632 } else {633 val next = !enabled634 enabled = next635 if (!next) onSave(false, webhookUrl, label)636 }637 }.padding(horizontal = 6.dp, vertical = 4.dp),638 )639 }640 Text(641 "Fires a silent webhook when this entry's password or username is copied.",642 color = peachColors.T2,643 style = PeachType.labelMono,644 modifier = Modifier.padding(top = 3.dp),645 )646 if (enabled) {647 Spacer(Modifier.height(8.dp))648 PeachField(649 label = "webhook url",650 value = webhookUrl,651 onChange = { webhookUrl = it },652 )653 Text(654 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",655 color = peachColors.T2,656 style = PeachType.labelMono,657 )658 PeachField(659 label = "label",660 value = label,661 onChange = { label = it },662 mono = false,663 )664 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {665 Text(666 "save",667 color = LocalPeachAccent.current,668 style = PeachType.labelMono,669 modifier = Modifier.clip(RoundedCornerShape(6.dp))670 .clickable { onSave(true, webhookUrl, label) }671 .padding(vertical = 4.dp),672 )673 Text(674 if (testing) "sending..." else "test ping",675 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,676 style = PeachType.labelMono,677 modifier = Modifier.clip(RoundedCornerShape(6.dp))678 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }679 .padding(vertical = 4.dp),680 )681 }682 }683 message?.let {684 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))685 }686 if (canary?.lastPinged != null || canary?.pingCount != null) {687 Text(688 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),689 color = peachColors.T2,690 style = PeachType.labelMono,691 modifier = Modifier.padding(top = 3.dp),692 )693 }694 }695 }696}697698@Composable699private fun EntryDetailBottomControls(700 currentIndex: Int,701 totalEntries: Int,702 onOpenList: () -> Unit,703 onSearch: () -> Unit,704 onAddEntry: () -> Unit,705 onOpenNotes: () -> Unit,706 onSettings: () -> Unit,707 onLock: () -> Unit,708 onPrevious: () -> Unit,709 onNext: () -> Unit,710 modifier: Modifier = Modifier,711) {712 val accent = LocalPeachAccent.current713 val hasEntries = totalEntries > 0 && currentIndex >= 0714 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"715716 Column(717 modifier718 .fillMaxWidth()719 .background(720 Brush.verticalGradient(721 listOf(Color.Transparent, peachColors.Bg.copy(alpha = 0.92f), peachColors.Bg),722 ),723 )724 ) {725 Box(726 Modifier.fillMaxWidth().height(84.dp),727 contentAlignment = Alignment.BottomCenter,728 ) {729 Row(730 Modifier.fillMaxWidth().height(44.dp),731 horizontalArrangement = Arrangement.Center,732 verticalAlignment = Alignment.CenterVertically,733 ) {734 Icon(735 Icons.AutoMirrored.Rounded.ArrowBack,736 contentDescription = "Previous entry",737 tint = if (hasEntries) peachColors.T0 else peachColors.T2,738 modifier = Modifier739 .size(36.dp)740 .clip(RoundedCornerShape(8.dp))741 .clickable(enabled = hasEntries, onClick = onPrevious)742 .padding(8.dp),743 )744 Spacer(Modifier.width(PeachSpacing.md))745 Text(counter, color = peachColors.T2, style = PeachType.labelMono)746 Spacer(Modifier.width(PeachSpacing.md))747 Spacer(Modifier.width(4.dp))748 Icon(749 Icons.AutoMirrored.Rounded.ArrowForward,750 contentDescription = "Next entry",751 tint = if (hasEntries) peachColors.T0 else peachColors.T2,752 modifier = Modifier753 .size(36.dp)754 .clip(RoundedCornerShape(8.dp))755 .clickable(enabled = hasEntries, onClick = onNext)756 .padding(8.dp),757 )758 }759 }760 Column(761 Modifier.fillMaxWidth()762 .navigationBarsPadding(),763 ) {764 PeachDeckBottomBar(765 onSearch = onSearch,766 onAdd = onAddEntry,767 onNotes = onOpenNotes,768 onSettings = onSettings,769 onLock = onLock,770 onSwipeUp = onOpenList,771 )772 }773 }774}775776777@Composable778internal fun FieldShell(779 label: String,780 modifier: Modifier = Modifier,781 stacked: Boolean = false,782 divider: Boolean = false,783 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,784) {785 if (stacked) {786 Column(modifier.fillMaxWidth()) {787 Column(Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)) {788 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))789 Spacer(Modifier.height(6.dp))790 Row(verticalAlignment = Alignment.CenterVertically, content = content)791 }792 if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))793 }794 return795 }796 Column(797 modifier.fillMaxWidth()798 .padding(vertical = PeachSpacing.xs)799 .clip(RoundedCornerShape(PeachRadius.StackCard))800 .background(peachColors.SurfaceCard)801 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))802 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),803 ) {804 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))805 Spacer(Modifier.height(6.dp))806 Row(verticalAlignment = Alignment.CenterVertically, content = content)807 }808}809810@Composable811private fun CredentialStack(content: @Composable () -> Unit) {812 Column(813 Modifier.fillMaxWidth()814 .padding(vertical = PeachSpacing.xs)815 .clip(RoundedCornerShape(PeachRadius.StackCard))816 .background(peachColors.SurfaceCard)817 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),818 ) { content() }819}820821822823824825internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }826827internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =828 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)829830internal fun shortCredential(credentialId: String): String {831 if (credentialId.length <= 12) return credentialId832 return credentialId.take(6) + "..." + credentialId.takeLast(4)833}834835internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {836 val count = pingCount ?: 0837 val countLabel = "$count total alert" + if (count == 1) "" else "s"838 if (lastPinged == null) return countLabel839 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)840 .format(java.util.Date(lastPinged))841 return "Last triggered: $date · $countLabel"842}