1package com.peachpasswords.ui.vault23import android.view.WindowManager4import androidx.compose.animation.AnimatedVisibility5import androidx.compose.animation.core.LinearEasing6import androidx.compose.animation.core.RepeatMode7import androidx.compose.animation.core.animateFloat8import androidx.compose.animation.core.animateFloatAsState9import androidx.compose.animation.core.infiniteRepeatable10import androidx.compose.animation.core.rememberInfiniteTransition11import androidx.compose.animation.core.tween12import androidx.compose.foundation.background13import androidx.compose.foundation.border14import androidx.compose.foundation.clickable15import androidx.compose.foundation.gestures.detectVerticalDragGestures16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.Column19import androidx.compose.foundation.layout.Row20import androidx.compose.foundation.layout.Spacer21import androidx.compose.foundation.layout.fillMaxSize22import androidx.compose.foundation.layout.fillMaxHeight23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.offset26import androidx.compose.foundation.layout.padding27import androidx.compose.foundation.layout.navigationBarsPadding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.layout.width31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.shape.CircleShape33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.text.BasicTextField35import androidx.compose.material.icons.rounded.Check36import androidx.compose.material.icons.rounded.ContentCopy37import androidx.compose.material.icons.rounded.Search38import androidx.compose.foundation.verticalScroll39import androidx.compose.material.icons.Icons40import androidx.compose.material.icons.automirrored.rounded.ArrowBack41import androidx.compose.material.icons.automirrored.rounded.ArrowForward42import androidx.compose.material.icons.outlined.DeleteOutline43import androidx.compose.material3.Icon44import androidx.compose.material3.Text45import androidx.compose.runtime.Composable46import androidx.compose.runtime.DisposableEffect47import 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.FaviconInitials79import com.peachpasswords.ui.components.TOTPRing80import com.peachpasswords.ui.lock.Biometrics81import com.peachpasswords.ui.theme.LocalPeachAccent82import com.peachpasswords.ui.theme.LocalReduceMotion83import com.peachpasswords.ui.theme.peachColors84import com.peachpasswords.ui.theme.PeachType85import com.peachpasswords.ui.theme.ShellBackground86import com.peachpasswords.vault.TOTPGenerator87import com.peachpasswords.vault.hasTOTP88import com.peachpasswords.vault.hasPasskey89import com.peachpasswords.vault.isBreach90import com.peachpasswords.vault.isCard91import com.peachpasswords.vault.isNote92import com.peachpasswords.vault.isPasskeyOnly9394@Composable95fun EntryDetailScreen(96 id: String,97 onNavigate: (PeachNav) -> Unit,98 onBack: () -> Unit,99 vm: EntryDetailViewModel = hiltViewModel(),100) {101 val entry by vm.entry.collectAsState()102 val activeEntries by vm.activeEntries.collectAsState()103 val identitySuggestions by vm.identitySuggestions.collectAsState()104 val allTags by vm.allTags.collectAsState()105 val cvcRevealed by vm.cvcRevealed.collectAsState()106 var cardRevealed by remember { mutableStateOf(false) }107 val copiedField by vm.copiedField.collectAsState()108 val context = LocalContext.current109 val accent = LocalPeachAccent.current110 var confirmingDelete by remember { mutableStateOf(false) }111 var generatorOpen by remember { mutableStateOf(false) }112 var historyExpanded by remember { mutableStateOf(false) }113114 115 DisposableEffect(Unit) {116 val window = (context as? FragmentActivity)?.window117 window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)118 onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }119 }120121 val e = entry122 ShellBackground {123 Box(Modifier.fillMaxSize().statusBarsPadding()) {124 Column(125 Modifier.fillMaxSize()126 .padding(bottom = 100.dp)127 .verticalScroll(rememberScrollState())128 .padding(horizontal = 24.dp),129 ) {130 131 Row(132 Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),133 verticalAlignment = Alignment.CenterVertically,134 ) {135 Icon(136 Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,137 modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp),138 )139 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {140 when {141 e == null -> {}142 confirmingDelete -> Text(143 "delete “${e.title}”?",144 color = peachColors.Breach,145 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),146 )147 else -> Row(148 verticalAlignment = Alignment.CenterVertically,149 horizontalArrangement = Arrangement.spacedBy(8.dp),150 ) {151 FaviconInitials(source = e.url ?: e.title, size = 16.dp)152 val domain = remember(e.url, e.title) { e.displayDomain() }153 if (domain.isNotEmpty()) {154 Text(domain, color = peachColors.T2, style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp))155 }156 }157 }158 }159 if (e != null) {160 Icon(161 Icons.Outlined.DeleteOutline, contentDescription = "delete",162 tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,163 modifier = Modifier.size(40.dp).clip(CircleShape)164 .clickable { if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true }165 .padding(8.dp),166 )167 }168 }169170 if (e == null) {171 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {172 Text("entry not found", color = peachColors.T1, style = PeachType.bodyMono)173 }174 return@Column175 }176177 178 if (e.isBreach()) {179 Row(180 Modifier.fillMaxWidth()181 .background(peachColors.BreachBg)182 .padding(horizontal = 16.dp, vertical = 8.dp),183 verticalAlignment = Alignment.CenterVertically,184 ) {185 Text("⚠", color = peachColors.Breach, style = PeachType.bodyMono)186 Spacer(Modifier.width(10.dp))187 Text(188 "Password found in a data breach — change it now",189 color = peachColors.Breach, style = PeachType.labelMono,190 modifier = Modifier.weight(1f),191 )192 Text(193 "Change password",194 color = accent, style = PeachType.labelMono,195 modifier = Modifier.clip(RoundedCornerShape(6.dp))196 .clickable { generatorOpen = true }197 .padding(horizontal = 6.dp, vertical = 4.dp),198 )199 }200 }201 if (entryHasHomographWarning(e)) {202 HomographWarning()203 }204205 206 207 208 Box(Modifier.fillMaxWidth().clipToBounds()) {209 Text(210 text = ghostWatermarkText(e.title),211 style = TextStyle(fontFamily = PeachType.Display, fontSize = 150.sp, fontStyle = FontStyle.Italic),212 color = peachColors.T0.copy(alpha = 0.03f),213 maxLines = 1,214 softWrap = false,215 overflow = TextOverflow.Clip,216 modifier = Modifier.align(Alignment.TopCenter).offset(y = (-16).dp),217 )218 Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {219 EditableTitleHero(220 title = e.title,221 onSave = { vm.updateField("title", it) },222 )223 }224 }225 Spacer(Modifier.height(20.dp))226227 if (e.isCard()) {228 CardDetailFields(229 entry = e,230 cardRevealed = cardRevealed,231 cvcRevealed = cvcRevealed,232 copiedField = copiedField,233 onToggleCardReveal = { cardRevealed = !cardRevealed },234 onRevealCvc = {235 val activity = context as? FragmentActivity236 if (activity != null && Biometrics.isAvailable(context)) {237 Biometrics.authenticate(activity, "Unlock Peach", e.title, onSuccess = vm::revealCvc)238 } else vm.revealCvc()239 },240 onMaskCvc = vm::maskCvc,241 onCopy = vm::copy,242 onSaveCard = vm::updateCard,243 copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },244 )245 } else {246 if (!e.isNote()) {247 EditableFieldRow(248 label = "url", field = "url", value = e.url.orEmpty(),249 copied = copiedField == "url",250 onCopy = { vm.copy("url", e.url) },251 onSave = { vm.updateField("url", it) },252 placeholder = "add url",253 )254 }255 e.username?.takeIf { it.isNotBlank() }?.let { v ->256 EditableFieldRow(257 label = "username", field = "username", value = v,258 copied = copiedField == "username",259 onCopy = { vm.copy("username", v) },260 onSave = { vm.updateField("username", it) },261 suggestions = identitySuggestions,262 )263 }264 e.email?.takeIf { it.isNotBlank() }?.let { v ->265 EditableFieldRow(266 label = "email", field = "email", value = v,267 copied = copiedField == "email",268 onCopy = { vm.copy("email", v) },269 onSave = { vm.updateField("email", it) },270 suggestions = identitySuggestions,271 )272 }273 AdditionalUrlRows(274 urls = e.additionalUrls.orEmpty(),275 copiedField = copiedField,276 onCopy = { key, value -> vm.copy(key, value) },277 onSave = { index, value -> vm.updateAdditionalUrl(index, value) },278 onAdd = vm::addAdditionalUrl,279 )280 if (e.hasPasskey()) {281 PasskeyDetailSection(e)282 }283 e.password?.takeIf { it.isNotBlank() }?.let { pw ->284 EditablePasswordRow(285 value = pw,286 copied = copiedField == "password",287 entryTitle = e.title,288 onCopy = { vm.copy("password", pw) },289 onSave = { vm.updateField("password", it) },290 onGenerate = { generatorOpen = true },291 )292 }293 val history = e.passwordHistory.orEmpty()294 if (history.isNotEmpty()) {295 PasswordHistoryToggle(296 count = history.size,297 expanded = historyExpanded,298 onToggle = { historyExpanded = !historyExpanded },299 )300 AnimatedVisibility(visible = historyExpanded) {301 Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {302 PasswordHistoryList(303 history = history,304 onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },305 onCopy = { oldPw -> vm.copy("password", oldPw) },306 )307 }308 }309 }310 if (e.hasTOTP()) {311 Spacer(Modifier.height(4.dp))312 TotpBandEditable(313 totpField = e.totp!!,314 copied = copiedField == "totp",315 onCopy = vm::copyTotp,316 onSaveSecret = { newSecret -> vm.updateField("totp", newSecret) },317 )318 }319 }320 e.notes?.takeIf { it.isNotBlank() }?.let { v ->321 EditableFieldRow(322 label = "notes", field = "notes", value = v,323 copied = copiedField == "notes",324 onCopy = { vm.copy("notes", v) },325 onSave = { vm.updateField("notes", it) },326 mono = false, singleLine = false,327 )328 }329 DetailTagRow(330 tags = e.tags.orEmpty(),331 allTags = allTags,332 onAddTag = vm::addTag,333 onRemoveTag = vm::removeTag,334 )335 e.canary?.let { canary ->336 CanaryDetailSection(337 label = canary.label,338 webhookUrl = canary.webhookUrl,339 lastPinged = canary.lastPinged,340 pingCount = canary.pingCount,341 )342 }343344 Spacer(Modifier.height(24.dp))345 }346347 if (e != null) {348 EntryDetailSearchStrip(349 currentIndex = activeEntries.indexOfFirst { it.id == e.id },350 totalEntries = activeEntries.size,351 onOpenList = { onNavigate(PeachNav.VaultList) },352 onPrevious = { vm.adjacentDestination(-1)?.let(onNavigate) },353 onNext = { vm.adjacentDestination(1)?.let(onNavigate) },354 modifier = Modifier.align(Alignment.BottomCenter),355 )356 }357358 if (generatorOpen) {359 GeneratorSheet(360 copied = copiedField == "genDraft",361 onCopy = { draft -> vm.copy("genDraft", draft) },362 onUse = { draft -> vm.applyGeneratedPassword(draft) { generatorOpen = false } },363 onDismiss = { generatorOpen = false },364 )365 }366 }367 }368}369370@Composable371private fun EditableTitleHero(title: String, onSave: (String) -> Unit) {372 val accent = LocalPeachAccent.current373 var editing by remember(title) { mutableStateOf(false) }374 var draft by remember(title) { mutableStateOf(title) }375 if (!editing) {376 Box(Modifier.fillMaxWidth().clickable { draft = title; editing = true }) {377 CharEnterText(378 text = title,379 style = PeachType.entryNameHero.copy(color = peachColors.T0),380 )381 }382 return383 }384 Column(Modifier.fillMaxWidth()) {385 BasicTextField(386 value = draft,387 onValueChange = { draft = it },388 singleLine = true,389 textStyle = PeachType.entryNameHero.copy(color = peachColors.T0),390 cursorBrush = SolidColor(accent),391 modifier = Modifier.fillMaxWidth().drawBehind {392 drawLine(393 color = accent,394 start = Offset(0f, size.height),395 end = Offset(size.width, size.height),396 strokeWidth = 1.dp.toPx(),397 )398 },399 )400 Spacer(Modifier.height(8.dp))401 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {402 Text(403 "✓ save", color = accent, style = PeachType.labelMono,404 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {405 onSave(draft)406 editing = false407 }.padding(vertical = 4.dp),408 )409 Text(410 "✗", color = peachColors.T2, style = PeachType.labelMono,411 modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {412 draft = title413 editing = false414 }.padding(horizontal = 6.dp, vertical = 4.dp),415 )416 }417 }418}419420@Composable421private fun HomographWarning() {422 Row(423 Modifier.fillMaxWidth()424 .padding(top = 8.dp)425 .clip(RoundedCornerShape(8.dp))426 .background(peachColors.BreachBg)427 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(8.dp))428 .padding(horizontal = 12.dp, vertical = 9.dp),429 verticalAlignment = Alignment.CenterVertically,430 ) {431 Text("!", color = peachColors.Breach, style = PeachType.labelMono)432 Spacer(Modifier.width(10.dp))433 Text(434 "This URL contains non-ASCII or punycode characters.",435 color = peachColors.Breach,436 style = PeachType.labelMono,437 modifier = Modifier.weight(1f),438 )439 }440}441442@Composable443private fun PasskeyDetailSection(entry: com.peachpasswords.vault.VaultEntry) {444 val passkey = entry.passkey ?: return445 FieldShell(if (entry.isPasskeyOnly()) "passkey only" else "passkey") {446 Column(Modifier.weight(1f)) {447 Text(448 passkey.rpName?.takeIf { it.isNotBlank() } ?: passkey.rpId,449 color = peachColors.T0,450 style = PeachType.body,451 )452 Text(453 listOfNotNull(454 passkey.rpId,455 passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: passkey.userName,456 ).joinToString(" · "),457 color = peachColors.T2,458 style = PeachType.labelMono,459 )460 Text(461 "credential ${shortCredential(passkey.credentialId)} · sign count ${passkey.signCount}",462 color = peachColors.T2,463 style = PeachType.labelMono,464 modifier = Modifier.padding(top = 3.dp),465 )466 }467 }468}469470@Composable471private fun CanaryDetailSection(label: String?, webhookUrl: String, lastPinged: Long?, pingCount: Int?) {472 FieldShell("canary alert") {473 Column(Modifier.weight(1f)) {474 Text(475 label?.takeIf { it.isNotBlank() } ?: "Enabled",476 color = peachColors.T0,477 style = PeachType.body,478 )479 Text(480 webhookUrl,481 color = peachColors.T2,482 style = PeachType.labelMono,483 maxLines = 1,484 overflow = TextOverflow.Ellipsis,485 )486 if (lastPinged != null || pingCount != null) {487 Text(488 canaryStatusLabel(lastPinged, pingCount),489 color = peachColors.T2,490 style = PeachType.labelMono,491 modifier = Modifier.padding(top = 3.dp),492 )493 }494 }495 }496}497498@Composable499private fun EntryDetailSearchStrip(500 currentIndex: Int,501 totalEntries: Int,502 onOpenList: () -> Unit,503 onPrevious: () -> Unit,504 onNext: () -> Unit,505 modifier: Modifier = Modifier,506) {507 val accent = LocalPeachAccent.current508 val hasEntries = totalEntries > 0 && currentIndex >= 0509 val counter = if (hasEntries) "${currentIndex + 1} / $totalEntries" else "0 entries"510511 Column(512 modifier513 .fillMaxWidth()514 .navigationBarsPadding()515 .padding(horizontal = 16.dp, vertical = 12.dp),516 ) {517 Row(518 Modifier519 .fillMaxWidth()520 .height(56.dp)521 .clip(RoundedCornerShape(16.dp))522 .background(peachColors.S1)523 .border(1.dp, peachColors.T3, RoundedCornerShape(16.dp))524 .padding(horizontal = 12.dp),525 verticalAlignment = Alignment.CenterVertically,526 ) {527 Row(528 Modifier529 .weight(1f)530 .fillMaxHeight()531 .clickable(onClick = onOpenList)532 .pointerInput(onOpenList) {533 var opened = false534 detectVerticalDragGestures(535 onDragEnd = { opened = false },536 onDragCancel = { opened = false },537 ) { _, dragAmount ->538 if (!opened && dragAmount < -4f) {539 opened = true540 onOpenList()541 }542 }543 },544 verticalAlignment = Alignment.CenterVertically,545 ) {546 Icon(547 Icons.Rounded.Search,548 contentDescription = null,549 tint = accent,550 modifier = Modifier.size(14.dp),551 )552 Spacer(Modifier.width(8.dp))553 Text(554 "search vault...",555 color = peachColors.T2,556 style = PeachType.searchInput,557 modifier = Modifier.weight(1f),558 )559 Text(560 counter,561 color = peachColors.T2,562 style = PeachType.labelMono,563 )564 }565 Spacer(Modifier.width(8.dp))566 Icon(567 Icons.AutoMirrored.Rounded.ArrowBack,568 contentDescription = "Previous entry",569 tint = if (hasEntries) peachColors.T0 else peachColors.T2,570 modifier = Modifier571 .size(38.dp)572 .clip(CircleShape)573 .clickable(enabled = hasEntries, onClick = onPrevious)574 .padding(9.dp),575 )576 Icon(577 Icons.AutoMirrored.Rounded.ArrowForward,578 contentDescription = "Next entry",579 tint = if (hasEntries) peachColors.T0 else peachColors.T2,580 modifier = Modifier581 .size(38.dp)582 .clip(CircleShape)583 .clickable(enabled = hasEntries, onClick = onNext)584 .padding(9.dp),585 )586 }587 }588}589590591@Composable592internal fun FieldShell(593 label: String,594 content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,595) {596 val hairline = peachColors.T3597 Column(598 Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {599 drawRect(600 color = hairline,601 topLeft = Offset(0f, size.height - 1.dp.toPx()),602 size = Size(size.width, 1.dp.toPx()),603 )604 },605 ) {606 Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))607 Spacer(Modifier.height(6.dp))608 Row(verticalAlignment = Alignment.CenterVertically, content = content)609 }610}611612613614615616internal fun ghostWatermarkText(title: String): String = title.ifBlank { "P" }617618internal fun entryHasHomographWarning(entry: com.peachpasswords.vault.VaultEntry): Boolean =619 (listOfNotNull(entry.url) + entry.additionalUrls.orEmpty()).any(::detectHomograph)620621internal fun shortCredential(credentialId: String): String {622 if (credentialId.length <= 12) return credentialId623 return credentialId.take(6) + "..." + credentialId.takeLast(4)624}625626internal fun canaryStatusLabel(lastPinged: Long?, pingCount: Int?): String {627 val count = pingCount ?: 0628 val countLabel = "$count total alert" + if (count == 1) "" else "s"629 if (lastPinged == null) return countLabel630 val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)631 .format(java.util.Date(lastPinged))632 return "Last triggered: $date · $countLabel"633}634635private fun com.peachpasswords.vault.VaultEntry.displayDomain(): String {636 val raw = url637 if (!raw.isNullOrBlank()) {638 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')639 if (cleaned.isNotEmpty()) return cleaned640 }641 return ""642}