CardDetailFields.kt

8.6 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: (cardNickname: String, 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 cardNickname by remember(entry.id, entry.modifiedAt) { mutableStateOf(entry.cardNickname.orEmpty()) }69        var cardholder by remember(entry.id, entry.modifiedAt) { mutableStateOf(entry.cardholderName.orEmpty()) }70        var expiry by remember(entry.id, entry.modifiedAt) {71            mutableStateOf(CardFieldUtils.displayExpiry(entry.expMonth, entry.expYear))72        }73        var cvc by remember(entry.id, entry.modifiedAt) { mutableStateOf(entry.cvc.orEmpty()) }74        var showCvc by remember { mutableStateOf(false) }75        val draftNetwork = CardFieldUtils.detectNetwork(cardNumber)76        CardEditFields(77            cardNickname = cardNickname,78            cardNumber = cardNumber,79            cardholderName = cardholder,80            expiry = expiry,81            cvc = cvc,82            showCvc = showCvc,83            network = draftNetwork,84            onCardNickname = { cardNickname = it },85            onCardNumber = { cardNumber = it },86            onCardholder = { cardholder = it },87            onExpiry = { expiry = it },88            onCvc = { cvc = it },89            onToggleShowCvc = { showCvc = !showCvc },90        )91        Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {92            Text(93                "✓ save", color = accent, style = PeachType.labelMono,94                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {95                    onSaveCard(cardNickname, cardNumber, cardholder, expiry, cvc)96                    editing = false97                }.padding(vertical = 4.dp),98            )99            Text(100                "✗", color = peachColors.T2, style = PeachType.labelMono,101                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { editing = false }.padding(horizontal = 6.dp, vertical = 4.dp),102            )103        }104        return105    }106107    Text(108        "edit card",109        color = accent,110        style = PeachType.labelMono,111        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable { editing = true }.padding(vertical = 4.dp),112    )113114    // CARD NUMBER — masked w/ last-4 by default; reveal shows full grouped PAN; copy = full unmasked.115    if (!rawNumber.isNullOrBlank()) {116        CardShell(117            label = "card number",118            labelTrailing = {119                val net = CardFieldUtils.networkLabel(network)120                if (net.isNotEmpty()) {121                    Text(net, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)122                }123            },124        ) {125            Text(126                text = if (cardRevealed) CardFieldUtils.formatPan(rawNumber, network)127                else CardFieldUtils.maskedPan(rawNumber, network, entry.last4),128                color = peachColors.T0,129                style = PeachType.bodyMono,130                modifier = Modifier.weight(1f),131            )132            Text(133                if (cardRevealed) "hide" else "show", color = accent, style = PeachType.labelMono,134                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleCardReveal).padding(8.dp),135            )136            copyButton(copiedField == "cardNumber") { onCopy("cardNumber", CardFieldUtils.digitsOnly(rawNumber)) }137        }138    }139140    entry.cardholderName?.takeIf { it.isNotBlank() }?.let { name ->141        CardShell("cardholder") {142            Text(name, color = peachColors.T0, style = PeachType.body, modifier = Modifier.weight(1f))143            copyButton(copiedField == "cardholderName") { onCopy("cardholderName", name) }144        }145    }146147    val expiry = CardFieldUtils.displayExpiry(entry.expMonth, entry.expYear)148    if (expiry.isNotBlank()) {149        CardShell("expiry") {150            Text(expiry, color = peachColors.T0, style = PeachType.bodyMono, modifier = Modifier.weight(1f))151            copyButton(copiedField == "expiry") { onCopy("expiry", expiry) }152        }153    }154155    // CVC — masked; reveal is biometric-gated (same path as password reveal).156    entry.cvc?.takeIf { it.isNotBlank() }?.let { cvc ->157        CardShell("cvc") {158            Text(159                text = if (cvcRevealed) cvc else "•".repeat(cvc.length.coerceIn(3, 4)),160                color = peachColors.T0,161                style = PeachType.bodyMono,162                modifier = Modifier.weight(1f),163            )164            Text(165                if (cvcRevealed) "hide" else "show", color = accent, style = PeachType.labelMono,166                modifier = Modifier.clip(RoundedCornerShape(6.dp))167                    .clickable { if (cvcRevealed) onMaskCvc() else onRevealCvc() }168                    .padding(8.dp),169            )170            copyButton(copiedField == "cvc") { onCopy("cvc", cvc) }171        }172    }173}174175/** Mirror of EntryDetailScreen's private `FieldShell` (label + value row + t3 hairline). */176@Composable177private fun CardShell(178    label: String,179    labelTrailing: (@Composable () -> Unit)? = null,180    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,181) {182    val hairline = peachColors.T3183    Column(184        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {185            drawRect(186                color = hairline,187                topLeft = Offset(0f, size.height - 1.dp.toPx()),188                size = Size(size.width, 1.dp.toPx()),189            )190        },191    ) {192        Row(verticalAlignment = Alignment.CenterVertically) {193            Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))194            if (labelTrailing != null) {195                Spacer(Modifier.width(8.dp))196                labelTrailing()197            }198        }199        Spacer(Modifier.height(6.dp))200        Row(verticalAlignment = Alignment.CenterVertically, content = content)201    }202}