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.Add33import androidx.compose.material.icons.rounded.Check34import androidx.compose.material.icons.rounded.ContentCopy35import androidx.compose.material.icons.rounded.Search36import androidx.compose.material.icons.rounded.Settings37import 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.Description42import androidx.compose.material.icons.outlined.Settings43import androidx.compose.material.icons.outlined.Lock44import androidx.compose.material3.Icon45import androidx.compose.material3.Text46import androidx.compose.runtime.Composable47import androidx.compose.runtime.getValue48import androidx.compose.runtime.mutableStateOf49import androidx.compose.runtime.produceState50import androidx.compose.runtime.remember51import androidx.compose.runtime.setValue52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.clipToBounds56import androidx.compose.ui.draw.drawBehind57import androidx.compose.ui.geometry.Offset58import androidx.compose.ui.geometry.Size59import androidx.compose.ui.graphics.Brush60import androidx.compose.ui.graphics.Color61import androidx.compose.ui.graphics.SolidColor62import androidx.compose.ui.graphics.graphicsLayer63import androidx.compose.ui.platform.LocalContext64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.text.TextStyle66import androidx.compose.ui.text.font.FontStyle67import androidx.compose.ui.text.style.TextOverflow68import androidx.compose.ui.unit.dp69import androidx.compose.ui.unit.em70import androidx.compose.ui.unit.sp71import androidx.compose.runtime.collectAsState72import androidx.fragment.app.FragmentActivity73import androidx.hilt.navigation.compose.hiltViewModel74import com.peachpasswords.shared.detectHomograph75import com.peachpasswords.ui.PeachNav76import com.peachpasswords.ui.components.CharEnterText77import com.peachpasswords.ui.components.CopyButton78import com.peachpasswords.ui.components.PeachEmptyOrEndState79import com.peachpasswords.ui.components.PeachWordmark80import 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.PeachRadius87import com.peachpasswords.ui.theme.peachColors88import com.peachpasswords.ui.theme.PeachType89import com.peachpasswords.ui.theme.ShellBackground90import com.peachpasswords.vault.TOTPGenerator91import com.peachpasswords.vault.hasTOTP92import com.peachpasswords.vault.hasPasskey93import com.peachpasswords.vault.isBreach94import com.peachpasswords.vault.isCard95import com.peachpasswords.vault.isNote96import com.peachpasswords.vault.isPasskeyOnly9798@Composable99fun EntryDetailScreen(100 id: String,101 onNavigate: (PeachNav) -> Unit,102 103 104 105 onBack: (() -> Unit)?,106 modifier: Modifier = Modifier,107 vm: EntryDetailViewModel = hiltViewModel(),108) {109 SecureFlagEffect()110111 val entry by vm.entry.collectAsState()112 val activeEntries by vm.activeEntries.collectAsState()113 val identitySuggestions by vm.identitySuggestions.collectAsState()114 val allTags by vm.allTags.collectAsState()115 val isPro by vm.isPro.collectAsState()116 val cvcRevealed by vm.cvcRevealed.collectAsState()117 var cardRevealed by remember { mutableStateOf(false) }118 val copiedField by vm.copiedField.collectAsState()119 val canaryMessage by vm.canaryMessage.collectAsState()120 val canaryTesting by vm.canaryTesting.collectAsState()121 val context = LocalContext.current122 val accent = LocalPeachAccent.current123 var confirmingDelete by remember { mutableStateOf(false) }124 var generatorOpen by remember { mutableStateOf(false) }125 var historyExpanded by remember { mutableStateOf(false) }126127 val e = entry128 ShellBackground(modifier) {129 Box(130 Modifier.fillMaxSize().statusBarsPadding()131 132 133 134 135 .pointerInput(vm) {136 var dragAccum = 0f137 detectHorizontalDragGestures(138 onDragStart = { dragAccum = 0f },139 onDragEnd = {140 when {141 dragAccum <= -120f -> vm.adjacentDestination(1)?.let(onNavigate)142 dragAccum >= 120f -> vm.adjacentDestination(-1)?.let(onNavigate)143 }144 dragAccum = 0f145 },146 onDragCancel = { dragAccum = 0f },147 ) { _, dragAmount -> dragAccum += dragAmount }148 },149 ) {150 Column(151 Modifier.fillMaxSize()152 .padding(bottom = 172.dp)153 .verticalScroll(rememberScrollState())154 .padding(horizontal = PeachSpacing.xl),155 ) {156 EntryDetailIdentity()157 Spacer(Modifier.height(PeachSpacing.xxl))158159 if (e == null) {160 PeachEmptyOrEndState(161 message = "entry not found",162 detail = "It may have been moved or deleted.",163 modifier = Modifier.padding(top = PeachSpacing.xxl),164 )165 return@Column166 }167168 169 if (e.isBreach()) {170 Row(171 Modifier.fillMaxWidth()172 .background(peachColors.BreachBg)173 .padding(horizontal = 16.dp, vertical = 8.dp),174 verticalAlignment = Alignment.CenterVertically,175 ) {176 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)177 Spacer(Modifier.width(10.dp))178 Text(179 "Password found in a data breach — change it now",180 color = peachColors.Breach, style = PeachType.labelMono,181 modifier = Modifier.weight(1f),182 )183 Text(184 "Change password",185 color = accent, style = PeachType.labelMono,186 modifier = Modifier.clip(RoundedCornerShape(6.dp))187 .clickable { generatorOpen = true }188 .padding(horizontal = 6.dp, vertical = 4.dp),189 )190 }191 }192 if (entryHasHomographWarning(e)) {193 HomographWarning()194 }195196 197 198 199 Box(Modifier.fillMaxWidth().clipToBounds()) {200 Text(201 text = ghostWatermarkText(e.title),202 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),203 color = peachColors.T0.copy(alpha = 0.03f),204 maxLines = 1,205 softWrap = false,206 overflow = TextOverflow.Clip,207 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),208 )209 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {210 EditableTitleHero(211 title = e.title,212 onSave = { vm.updateField("title", it) },213 )214 }215 }216 Spacer(Modifier.height(PeachSpacing.lg))217218 if (e.isCard()) {219 CardDetailFields(220 entry = e,221 cardRevealed = cardRevealed,222 cvcRevealed = cvcRevealed,223 copiedField = copiedField,224 onToggleCardReveal = { cardRevealed = !cardRevealed },225 onRevealCvc = {226 val activity = context as? FragmentActivity227 if (activity != null && Biometrics.isAvailable(context)) {228 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)229 } else vm.revealCvc()230 },231 onMaskCvc = vm::maskCvc,232 onCopy = vm::copy,233 onSaveCard = vm::updateCard,234 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },235 )236 } else {237 if (!e.isNote()) {238 EditableFieldRow(239 label = "url", field = "url", value = e.url.orEmpty(),240 copied = copiedField == "url",241 onCopy = { vm.copy("url", e.url) },242 onSave = { vm.updateField("url", it) },243 placeholder = "add url",244 )245 }246 247 248 249 250 251 var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }252 var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }253 if (showUsernameField) {254 EditableFieldRow(255 label = "username", field = "username", value = e.username.orEmpty(),256 copied = copiedField == "username",257 onCopy = { vm.copy("username", e.username) },258 onSave = { vm.updateField("username", it) },259 suggestions = identitySuggestions,260 placeholder = "add username",261 )262 }263 if (showEmailField) {264 EditableFieldRow(265 label = "email", field = "email", value = e.email.orEmpty(),266 copied = copiedField == "email",267 onCopy = { vm.copy("email", e.email) },268 onSave = { vm.updateField("email", it) },269 suggestions = identitySuggestions,270 placeholder = "add email",271 )272 }273 if (!showUsernameField || !showEmailField) {274 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {275 if (!showUsernameField) {276 Text(277 "+ add username", color = accent, style = PeachType.labelMono,278 modifier = Modifier279 .clip(RoundedCornerShape(6.dp))280 .clickable { showUsernameField = true }281 .padding(vertical = 6.dp),282 )283 }284 if (!showEmailField) {285 Text(286 "+ add email", color = accent, style = PeachType.labelMono,287 modifier = Modifier288 .clip(RoundedCornerShape(6.dp))289 .clickable { showEmailField = true }290 .padding(vertical = 6.dp),291 )292 }293 }294 }295 AdditionalUrlRows(296 urls = e.additionalUrls.orEmpty(),297 copiedField = copiedField,298 onCopy = { key, value -> vm.copy(key, value) },299 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },300 onAdd = vm::addAdditionalUrl,301 )302 if (e.hasPasskey()) {303 PasskeyDetailSection(e)304 }305 e.password?.takeIf { it.isNotBlank() }?.let { pw ->306 EditablePasswordRow(307 value = pw,308 copied = copiedField == "password",309 entryTitle = e.title,310 onCopy = { vm.copy("password", pw) },311 onSave = { vm.updateField("password", it) },312 onGenerate = { generatorOpen = true },313 )314 }315 316 317 318 319 val history = e.passwordHistory.orEmpty()320 PasswordHistoryToggle(321 count = history.size,322 expanded = historyExpanded,323 onToggle = { historyExpanded = !historyExpanded },324 )325 AnimatedVisibility(visible = historyExpanded) {326 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {327 PasswordHistoryList(328 history = history,329 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },330 onCopy = { oldPw -> vm.copy("password", oldPw) },331 )332 }333 }334 if (e.hasTOTP()) {335 Spacer(Modifier.height(4.dp))336 TotpBandEditable(337 totpField = e.totp!!,338 copied = copiedField == "totp",339 onCopy = vm::copyTotp,340 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },341 )342 }343 }344 e.notes?.takeIf { it.isNotBlank() }?.let { v ->345 346 347 val notesAreHtml = isNoteHtml(v)348 EditableFieldRow(349 label = "notes", field = "notes", value = v,350 copied = copiedField == "notes",351 onCopy = { vm.copy("notes", notePlainText(v)) },352 onSave = { vm.updateField("notes", noteStorageText(it, notesAreHtml)) },353 mono = false, singleLine = false,354 displayText = noteRendered(v),355 draftText = noteEditorText(v),356 )357 }358 EntryDetailActions(359 confirmingDelete = confirmingDelete,360 onEdit = { onNavigate(PeachNav.EntryEdit(e.id)) },361 onDelete = {362 if (confirmingDelete) {363 vm.delete(onDone = onBack ?: { onNavigate(PeachNav.VaultList) })364 } else {365 confirmingDelete = true366 }367 },368 )369 Spacer(Modifier.height(PeachSpacing.md))370 DetailTagRow(371 tags = e.tags.orEmpty(),372 allTags = allTags,373 onAddTag = vm::addTag,374 onRemoveTag = vm::removeTag,375 )376 if (!e.isNote() && !e.isCard()) {377 378 379 380 381 var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }382 if (advancedExpanded) {383 CanaryDetailSection(384 entryId = e.id,385 modifiedAt = e.modifiedAt,386 canary = e.canary,387 isPro = isPro,388 message = canaryMessage,389 testing = canaryTesting,390 onSave = vm::saveCanary,391 onTest = vm::testCanary,392 )393 } else {394 Text(395 "+ advanced",396 color = LocalPeachAccent.current,397 style = PeachType.labelMono,398 modifier = Modifier399 .clip(RoundedCornerShape(6.dp))400 .clickable { advancedExpanded = true }401 .padding(vertical = 6.dp),402 )403 }404 }405406 Spacer(Modifier.height(PeachSpacing.xxl))407 }408409 if (e != null) {410 EntryDetailBottomControls(411 currentIndex = activeEntries.indexOfFirst { it.id == e.id },412 totalEntries = activeEntries.size,413 onOpenList = { onNavigate(PeachNav.VaultList) },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 EntryDetailIdentity(438) {439 Row(440 Modifier.fillMaxWidth().height(64.dp),441 verticalAlignment = Alignment.CenterVertically,442 ) {443 PeachWordmark()444 Spacer(Modifier.width(PeachSpacing.md))445 Text("VAULT", color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))446 }447}448449@Composable450private fun EntryDetailActions(451 confirmingDelete: Boolean,452 onEdit: () -> Unit,453 onDelete: () -> Unit,454) {455 val accent = LocalPeachAccent.current456 Row(457 Modifier.fillMaxWidth().padding(top = PeachSpacing.lg),458 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),459 ) {460 DetailActionButton(461 label = "edit",462 color = peachColors.Bg,463 background = accent,464 border = accent,465 onClick = onEdit,466 modifier = Modifier.weight(1f),467 )468 DetailActionButton(469 label = if (confirmingDelete) "confirm trash" else "move to trash",470 color = if (confirmingDelete) peachColors.Breach else accent,471 background = Color.Transparent,472 border = if (confirmingDelete) peachColors.BreachBorder else peachColors.T3,473 onClick = onDelete,474 modifier = Modifier.weight(1f),475 )476 }477}478479@Composable480private fun DetailActionButton(481 label: String,482 color: Color,483 background: Color,484 border: Color,485 onClick: () -> Unit,486 modifier: Modifier = Modifier,487) {488 Box(489 modifier.height(52.dp)490 .clip(RoundedCornerShape(PeachRadius.Button))491 .background(background)492 .border(1.dp, border, RoundedCornerShape(PeachRadius.Button))493 .clickable(onClick = onClick),494 contentAlignment = Alignment.Center,495 ) {496 Text(label, color = color, style = PeachType.bodyMono)497 }498}499500@Composable501private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {502 val accent = LocalPeachAccent.current503 var editing by remember(title) { mutableStateOf(false) }504 var draft by remember(title) { mutableStateOf(title) }505 if (!editing) {506 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {507 CharEnterText(508 text = title,509 style = PeachType.entryNameHero.copy(color = peachColors.T0),510 )511 }512 return513 }514 Column(Modifier.fillMaxWidth()) {515 BasicTextField(516 value = draft,517 onValueChange = { draft = it },518 singleLine = true,519 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),520 cursorBrush = SolidColor(accent),521 modifier = Modifier.fillMaxWidth().drawBehind {522 drawLine(523 color = accent,524 start = Offset(0f, size.height),525 end = Offset(size.width, size.height),526 strokeWidth = 1.dp.toPx(),527 )528 },529 )530 Spacer(Modifier.height(8.dp))531 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {532 Text(533 "✓ save", color = accent, style = PeachType.labelMono,534 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {535 onSave(draft)536 editing = false537 }.padding(vertical = 4.dp),538 )539 Text(540 "✗", color = peachColors.T2, style = PeachType.labelMono,541 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {542 draft = title543 editing = false544 }.padding(horizontal = 6.dp, vertical = 4.dp),545 )546 }547 }548}549550@Composable551private fun HomographWarning() {552 Row(553 Modifier.fillMaxWidth()554 .padding(top = 8.dp)555 .clip(RoundedCornerShape(8.dp))556 .background(peachColors.BreachBg)557 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))558 .padding(horizontal = 12.dp, vertical = 9.dp),559 verticalAlignment = Alignment.CenterVertically,560 ) {561 Text("!", color = peachColors.Breach, style = PeachType.labelMono)562 Spacer(Modifier.width(10.dp))563 Text(564 "This URL contains non-ASCII or punycode characters.",565 color = peachColors.Breach,566 style = PeachType.labelMono,567 modifier = Modifier.weight(1f),568 )569 }570}571572@Composable573private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {574 val passkey = entry.passkey ?: return575 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {576 Column(Modifier.weight(1f)) {577 Text(578 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,579 color = peachColors.T0,580 style = PeachType.body,581 )582 Text(583 listOfNotNull(584 passkey.rpId,585 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,586 ).joinToString(" · "),587 color = peachColors.T2,588 style = PeachType.labelMono,589 )590 Text(591 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",592 color = peachColors.T2,593 style = PeachType.labelMono,594 modifier = Modifier.padding(top = 3.dp),595 )596 }597 }598}599600@Composable601private fun CanaryDetailSection(602 entryId: String,603 modifiedAt: Long,604 canary: com.peachpasswords.vault.Canary?,605 isPro: Boolean,606 message: String?,607 testing: Boolean,608 onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,609 onTest: (webhookUrl: String) -> Unit,610) {611 var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }612 var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }613 var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }614 val gated = !isPro && canary == null615 FieldShell("canary alert") {616 Column(Modifier.weight(1f)) {617 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {618 Text(619 label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",620 color = peachColors.T0,621 style = PeachType.body,622 modifier = Modifier.weight(1f),623 )624 if (gated) {625 Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)626 }627 Text(628 if (enabled) "on" else "off",629 color = if (enabled) LocalPeachAccent.current else peachColors.T2,630 style = PeachType.labelMono,631 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {632 if (gated) {633 onSave(true, webhookUrl, label)634 } else {635 val next = !enabled636 enabled = next637 if (!next) onSave(false, webhookUrl, label)638 }639 }.padding(horizontal = 6.dp, vertical = 4.dp),640 )641 }642 Text(643 "Fires a silent webhook when this entry's password or username is copied.",644 color = peachColors.T2,645 style = PeachType.labelMono,646 modifier = Modifier.padding(top = 3.dp),647 )648 if (enabled) {649 Spacer(Modifier.height(8.dp))650 PeachField(651 label = "webhook url",652 value = webhookUrl,653 onChange = { webhookUrl = it },654 )655 Text(656 "When triggered, the entry title is sent to this URL. Use an endpoint you control.",657 color = peachColors.T2,658 style = PeachType.labelMono,659 )660 PeachField(661 label = "label",662 value = label,663 onChange = { label = it },664 mono = false,665 )666 Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {667 Text(668 "save",669 color = LocalPeachAccent.current,670 style = PeachType.labelMono,671 modifier = Modifier.clip(RoundedCornerShape(6.dp))672 .clickable { onSave(true, webhookUrl, label) }673 .padding(vertical = 4.dp),674 )675 Text(676 if (testing) "sending..." else "test ping",677 color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,678 style = PeachType.labelMono,679 modifier = Modifier.clip(RoundedCornerShape(6.dp))680 .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }681 .padding(vertical = 4.dp),682 )683 }684 }685 message?.let {686 Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))687 }688 if (canary?.lastPinged != null || canary?.pingCount != null) {689 Text(690 canaryStatusLabel(canary?.lastPinged, canary?.pingCount),691 color = peachColors.T2,692 style = PeachType.labelMono,693 modifier = Modifier.padding(top = 3.dp),694 )695 }696 }697 }698}699700@Composable701private fun EntryDetailBottomControls(702 currentIndex: Int,703 totalEntries: Int,704 onOpenList: () -> Unit,705 onAddEntry: () -> Unit,706 onOpenNotes: () -> Unit,707 onSettings: () -> Unit,708 onLock: () -> Unit,709 onPrevious: () -> Unit,710 onNext: () -> Unit,711 modifier: Modifier = Modifier,712) {713 val accent = LocalPeachAccent.current714 val hasEntries = totalEntries > 0 && currentIndex >= 0715 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"716717 Column(718 modifier719 .fillMaxWidth()720 ) {721 722 723 Box(724 Modifier.fillMaxWidth().height(40.dp)725 .background(Brush.verticalGradient(listOf(Color.Transparent, peachColors.Bg))),726 )727 Column(728 Modifier.fillMaxWidth()729 .background(peachColors.Bg)730 .navigationBarsPadding(),731 ) {732 Row(733 Modifier.fillMaxWidth().height(44.dp),734 horizontalArrangement = Arrangement.Center,735 verticalAlignment = Alignment.CenterVertically,736 ) {737 Icon(738 Icons.AutoMirrored.Rounded.ArrowBack,739 contentDescription = "Previous entry",740 tint = if (hasEntries) peachColors.T0 else peachColors.T2,741 modifier = Modifier742 .size(36.dp)743 .clip(RoundedCornerShape(8.dp))744 .clickable(enabled = hasEntries, onClick = onPrevious)745 .padding(8.dp),746 )747 Spacer(Modifier.width(PeachSpacing.md))748 Text(counter, color = peachColors.T2, style = PeachType.labelMono)749 Spacer(Modifier.width(PeachSpacing.md))750 Spacer(Modifier.width(4.dp))751 Icon(752 Icons.AutoMirrored.Rounded.ArrowForward,753 contentDescription = "Next entry",754 tint = if (hasEntries) peachColors.T0 else peachColors.T2,755 modifier = Modifier756 .size(36.dp)757 .clip(RoundedCornerShape(8.dp))758 .clickable(enabled = hasEntries, onClick = onNext)759 .padding(8.dp),760 )761 }762 Row(763 Modifier.fillMaxWidth()764 .padding(horizontal = PeachSpacing.xl, vertical = PeachSpacing.sm)765 .height(64.dp)766 .clip(RoundedCornerShape(PeachRadius.Pill))767 .background(peachColors.S1)768 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.Pill))769 .padding(horizontal = PeachSpacing.md),770 horizontalArrangement = Arrangement.SpaceBetween,771 verticalAlignment = Alignment.CenterVertically,772 ) {773 BottomBarAction(Icons.Rounded.Search, "search vault", accent, onOpenList)774 BottomBarAction(Icons.Rounded.Add, "new entry", peachColors.T0, onAddEntry)775 BottomBarAction(Icons.Outlined.Description, "Notes", peachColors.T0, onOpenNotes)776 BottomBarAction(Icons.Outlined.Settings, "settings", peachColors.T0, onSettings)777 BottomBarAction(Icons.Outlined.Lock, "Lock vault", accent, onLock)778 }779 }780 }781}782783@Composable784private fun BottomBarAction(785 icon: androidx.compose.ui.graphics.vector.ImageVector,786 label: String,787 tint: Color,788 onClick: () -> Unit,789) {790 Box(791 Modifier.size(48.dp)792 .clip(RoundedCornerShape(PeachRadius.IconButton))793 .clickable(onClick = onClick),794 contentAlignment = Alignment.Center,795 ) {796 Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(22.dp))797 }798}799800801@Composable802internal fun FieldShell(803 label: String,804 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,805) {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 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),813 ) {814 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))815 Spacer(Modifier.height(6.dp))816 Row(verticalAlignment = Alignment.CenterVertically, content = content)817 }818}819820821822823824internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }825826internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =827 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)828829internal fun shortCredential(credentialId: String): String {830 if (credentialId.length <= 12) return credentialId831 return credentialId.take(6) + "..." + credentialId.takeLast(4)832}833834internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {835 val count = pingCount ?: 0836 val countLabel = "$count total alert" + if (count == 1) "" else "s"837 if (lastPinged == null) return countLabel838 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)839 .format(java.util.Date(lastPinged))840 return "Last triggered: $date · $countLabel"841}