EntryDetailScreen.kt

27.5 KB · raw

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    // FLAG_SECURE only while this screen is shown — blocks screenshots of secrets.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                // Header: back · favicon+domain (or delete confirm) · trash131                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                // D11 — breach banner strip when this entry's password is known-compromised.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                // Hero — ghost PeachScript watermark behind the entry name. The full206                // title is drawn (not just its first letter); a long title overflows207                // and clips naturally, which is the intended watermark behavior.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}589590/** A field row: uppercase mono label over a value/action row, with a t3 hairline beneath (spec §5.4). */591@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}611612/**613 * Text for the ghost PeachScript watermark behind the entry name: the full title,614 * falling back to "P" for a blank title so the hero never renders empty.615 */616internal 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}