CardDetailFields.kt

8.4 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.clickable4import androidx.compose.foundation.layout.Arrangement5import androidx.compose.foundation.layout.Column6import androidx.compose.foundation.layout.Row7import androidx.compose.foundation.layout.Spacer8import androidx.compose.foundation.layout.fillMaxWidth9import androidx.compose.foundation.layout.height10import androidx.compose.foundation.layout.padding11import androidx.compose.foundation.layout.width12import androidx.compose.foundation.shape.RoundedCornerShape13import androidx.compose.material3.Text14import androidx.compose.runtime.Composable15import androidx.compose.runtime.getValue16import androidx.compose.runtime.mutableStateOf17import androidx.compose.runtime.remember18import androidx.compose.runtime.setValue19import androidx.compose.ui.Alignment20import androidx.compose.ui.Modifier21import androidx.compose.ui.draw.clip22import androidx.compose.ui.draw.drawBehind23import androidx.compose.ui.geometry.Offset24import androidx.compose.ui.geometry.Size25import androidx.compose.ui.text.TextStyle26import androidx.compose.ui.unit.em27import androidx.compose.ui.unit.dp28import androidx.compose.ui.unit.sp29import com.peachpasswords.ui.theme.LocalPeachAccent30import com.peachpasswords.ui.theme.peachColors31import com.peachpasswords.ui.theme.PeachType32import com.peachpasswords.vault.VaultEntry3334/**35 * D2 — EntryDetail CARD display block. Drop a single call to [CardDetailFields]36 * where the login username/password/notes rows are rendered, gated by37 * `e.isCard()`.38 *39 * This file defines its own row primitives so it does not depend on the private40 * `FieldShell`/`CopyButton` in EntryDetailScreen.kt. The orchestrator may instead41 * pass the existing `CopyButton`/`FieldShell` in (see INTEGRATION.md) — the42 * preferred wiring — but these mirrors keep the draft compilable in isolation.43 *44 * Reveal/biometric gating mirrors the password row exactly: CARD NUMBER reveal is45 * a plain local toggle (full PAN is shown), while CVC reveal is biometric-gated46 * via the same `onRevealCvc` callback the host wires to Biometrics + vm.reveal().47 */48@Composable49fun CardDetailFields(50    entry: VaultEntry,51    cardRevealed: Boolean,52    cvcRevealed: Boolean,53    copiedField: String?,54    onToggleCardReveal: () -> Unit,55    onRevealCvc: () -> Unit,56    onMaskCvc: () -> Unit,57    onCopy: (field: String, value: String?) -> Unit,58    onSaveCard: (cardNumber: String, cardholderName: String, expiry: String, cvc: String) -> Unit,59    copyButton: @Composable (copied: Boolean, onCopy: () -> Unit) -> Unit,60) {61    val accent = LocalPeachAccent.current62    val rawNumber = entry.cardNumber63    val network = entry.cardNetwork ?: CardFieldUtils.detectNetwork(rawNumber.orEmpty())64    var editing by remember(entry.id, entry.modifiedAt) { mutableStateOf(false) }6566    if (editing) {67        var cardNumber by remember(entry.id, entry.modifiedAt) { mutableStateOf(CardFieldUtils.formatPan(entry.cardNumber.orEmpty(), network)) }68        var cardholder by remember(entry.id, entry.modifiedAt) { mutableStateOf(entry.cardholderName.orEmpty()) }69        var expiry by remember(entry.id, entry.modifiedAt) {70            mutableStateOf(CardFieldUtils.displayExpiry(entry.expMonth, entry.expYear))71        }72        var cvc by remember(entry.id, entry.modifiedAt) { mutableStateOf(entry.cvc.orEmpty()) }73        var showCvc by remember { mutableStateOf(false) }74        val draftNetwork = CardFieldUtils.detectNetwork(cardNumber)75        CardEditFields(76            cardNumber = cardNumber,77            cardholderName = cardholder,78            expiry = expiry,79            cvc = cvc,80            showCvc = showCvc,81            network = draftNetwork,82            onCardNumber = { cardNumber = it },83            onCardholder = { cardholder = it },84            onExpiry = { expiry = it },85            onCvc = { cvc = it },86            onToggleShowCvc = { showCvc = !showCvc },87        )88        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {89            Text(90                "✓ save", color = accent, style = PeachType.labelMono,91                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {92                    onSaveCard(cardNumber, cardholder, expiry, cvc)93                    editing = false94                }.padding(vertical = 4.dp),95            )96            Text(97                "✗", color = peachColors.T2, style = PeachType.labelMono,98                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { editing = false }.padding(horizontal = 6.dp, vertical = 4.dp),99            )100        }101        return102    }103104    Text(105        "edit card",106        color = accent,107        style = PeachType.labelMono,108        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { editing = true }.padding(vertical = 4.dp),109    )110111    // CARD NUMBER — masked w/ last-4 by default; reveal shows full grouped PAN; copy = full unmasked.112    if (!rawNumber.isNullOrBlank()) {113        CardShell(114            label = "card number",115            labelTrailing = {116                val net = CardFieldUtils.networkLabel(network)117                if (net.isNotEmpty()) {118                    Text(net, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)119                }120            },121        ) {122            Text(123                text = if (cardRevealed) CardFieldUtils.formatPan(rawNumber, network)124                else CardFieldUtils.maskedPan(rawNumber, network, entry.last4),125                color = peachColors.T0,126                style = PeachType.bodyMono,127                modifier = Modifier.weight(1f),128            )129            Text(130                if (cardRevealed) "hide" else "show", color = accent, style = PeachType.labelMono,131                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleCardReveal).padding(8.dp),132            )133            copyButton(copiedField == "cardNumber") { onCopy("cardNumber", CardFieldUtils.digitsOnly(rawNumber)) }134        }135    }136137    entry.cardholderName?.takeIf { it.isNotBlank() }?.let { name ->138        CardShell("cardholder") {139            Text(name, color = peachColors.T0, style = PeachType.body, modifier = Modifier.weight(1f))140            copyButton(copiedField == "cardholderName") { onCopy("cardholderName", name) }141        }142    }143144    val expiry = CardFieldUtils.displayExpiry(entry.expMonth, entry.expYear)145    if (expiry.isNotBlank()) {146        CardShell("expiry") {147            Text(expiry, color = peachColors.T0, style = PeachType.bodyMono, modifier = Modifier.weight(1f))148            copyButton(copiedField == "expiry") { onCopy("expiry", expiry) }149        }150    }151152    // CVC — masked; reveal is biometric-gated (same path as password reveal).153    entry.cvc?.takeIf { it.isNotBlank() }?.let { cvc ->154        CardShell("cvc") {155            Text(156                text = if (cvcRevealed) cvc else "•".repeat(cvc.length.coerceIn(3, 4)),157                color = peachColors.T0,158                style = PeachType.bodyMono,159                modifier = Modifier.weight(1f),160            )161            Text(162                if (cvcRevealed) "hide" else "show", color = accent, style = PeachType.labelMono,163                modifier = Modifier.clip(RoundedCornerShape(6.dp))164                    .clickable { if (cvcRevealed) onMaskCvc() else onRevealCvc() }165                    .padding(8.dp),166            )167            copyButton(copiedField == "cvc") { onCopy("cvc", cvc) }168        }169    }170}171172/** Mirror of EntryDetailScreen's private `FieldShell` (label + value row + t3 hairline). */173@Composable174private fun CardShell(175    label: String,176    labelTrailing: (@Composable () -> Unit)? = null,177    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,178) {179    val hairline = peachColors.T3180    Column(181        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {182            drawRect(183                color = hairline,184                topLeft = Offset(0f, size.height - 1.dp.toPx()),185                size = Size(size.width, 1.dp.toPx()),186            )187        },188    ) {189        Row(verticalAlignment = Alignment.CenterVertically) {190            Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))191            if (labelTrailing != null) {192                Spacer(Modifier.width(8.dp))193                labelTrailing()194            }195        }196        Spacer(Modifier.height(6.dp))197        Row(verticalAlignment = Alignment.CenterVertically, content = content)198    }199}