EntryEditScreen.kt

18.2 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.core.animateDpAsState4import androidx.compose.animation.core.tween5import androidx.compose.foundation.background6import androidx.compose.foundation.border7import androidx.compose.foundation.clickable8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.offset17import androidx.compose.foundation.layout.padding18import androidx.compose.foundation.layout.statusBarsPadding19import androidx.compose.foundation.layout.width20import androidx.compose.foundation.rememberScrollState21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.verticalScroll23import androidx.compose.material3.Slider24import androidx.compose.material3.SliderDefaults25import androidx.compose.material3.Text26import androidx.compose.runtime.Composable27import androidx.compose.runtime.getValue28import androidx.compose.runtime.mutableStateOf29import androidx.compose.runtime.remember30import androidx.compose.runtime.setValue31import androidx.compose.ui.Alignment32import androidx.compose.ui.Modifier33import androidx.compose.ui.draw.clip34import androidx.compose.ui.draw.clipToBounds35import androidx.compose.ui.graphics.Color36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.font.FontStyle38import androidx.compose.ui.text.font.FontWeight39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.text.input.VisualTransformation41import androidx.compose.ui.unit.dp42import androidx.compose.ui.unit.em43import androidx.compose.ui.unit.sp44import androidx.compose.runtime.collectAsState45import androidx.hilt.navigation.compose.hiltViewModel46import com.peachpasswords.ui.components.CopyButton47import com.peachpasswords.ui.theme.LocalPeachAccent48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.ui.theme.PeachMotion50import com.peachpasswords.ui.theme.PeachRadius51import com.peachpasswords.ui.theme.PeachType52import com.peachpasswords.ui.theme.ShellBackground5354@Composable55fun EntryEditScreen(56    id: String?,57    onBack: () -> Unit,58    onOpenNote: (String) -> Unit = {},59    vm: EntryEditViewModel = hiltViewModel(),60) {61    val f by vm.form.collectAsState()62    val usernameSuggestions by vm.usernameSuggestions.collectAsState()63    val accent = LocalPeachAccent.current6465    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.66    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(67        targetValue = editGhostWatermarkTarget(f.title),68        animationSpec = tween(300),69        label = "edit-watermark",70    )7172    ShellBackground {73        Column(Modifier.fillMaxSize().statusBarsPadding()) {74            Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {75                val canSave = canSaveEditForm(f)76                Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,77                    modifier = Modifier.align(Alignment.CenterStart)78                        .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))79                Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,80                    fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,81                    modifier = Modifier.align(Alignment.Center))82                Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,83                    modifier = Modifier.align(Alignment.CenterEnd)84                        .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))85            }8687            Box(Modifier.fillMaxSize().clipToBounds()) {88                // Ghost PeachScript watermark behind the form (matches the detail hero):89                // the full title, fading in as the user types.90                if (watermarkAlpha > 0f) {91                    Text(92                        text = ghostWatermarkText(f.title),93                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),94                        color = peachColors.T0.copy(alpha = watermarkAlpha),95                        maxLines = 1,96                        softWrap = false,97                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,98                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),99                    )100                }101                Column(102                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),103                ) {104                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {105                    listOf("login", "note", "card").forEach { t ->106                        EntryTypePill(t, f.type == t) {107                            // A brand-new note skips the form entirely and drops straight108                            // into the full-screen notes editor (brief Fix 6 item 7).109                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)110                            else vm.setType(t)111                        }112                    }113                }114                Spacer(Modifier.height(18.dp))115116                Field("title", f.title, vm::setTitle)117                if (f.type == "login") {118                    Field("url", f.url, vm::setUrl)119                    AdditionalUrlsEditor(120                        urls = f.additionalUrls,121                        onChange = vm::setAdditionalUrl,122                        onAdd = vm::addAdditionalUrl,123                        onRemove = vm::removeAdditionalUrl,124                    )125                    IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)126                    IdentitySuggestField("email", f.email, usernameSuggestions, vm::setEmail)127                    PasswordField(128                        value = f.password,129                        show = f.showPassword,130                        onChange = vm::setPassword,131                        onToggleShow = vm::toggleShowPassword,132                        onToggleGenerator = vm::toggleGenerator,133                    )134                    GeneratorDrawer(135                        open = f.generatorOpen,136                        length = f.genLength,137                        upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,138                        password = f.password,139                        copied = f.genCopied,140                        onLength = vm::setGenLength,141                        onUpper = vm::setGenUpper, onLower = vm::setGenLower,142                        onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,143                        onGenerate = vm::generate,144                        onCopy = vm::copyGenerated,145                    )146                    PasswordHistorySection(147                        history = f.history,148                        expanded = f.showHistory,149                        onToggle = vm::toggleHistory,150                        onRestore = vm::restorePassword,151                        onCopy = vm::copyHistoryPassword,152                    )153                    if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {154                        Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)155                    }156                }157                if (f.type == "card") {158                    CardEditFields(159                        cardNumber = f.cardNumber,160                        cardholderName = f.cardholderName,161                        expiry = f.expiry,162                        cvc = f.cvc,163                        showCvc = f.showCvc,164                        network = f.cardNetwork,165                        onCardNumber = vm::setCardNumber,166                        onCardholder = vm::setCardholderName,167                        onExpiry = vm::setExpiry,168                        onCvc = vm::setCvc,169                        onToggleShowCvc = vm::toggleShowCvc,170                    )171                }172                if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {173                    Field("notes", f.notes, vm::setNotes, singleLine = false)174                }175                if (f.type == "login") {176                    OptionalLoginAddLinks(177                        showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),178                        showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),179                        onAddTotp = vm::revealTotp,180                        onAddNotes = vm::revealNotes,181                    )182                }183                TagChipEditor(184                    tags = f.tags,185                    allTags = vm.allTags,186                    onAdd = vm::addTag,187                    onRemove = vm::removeTag,188                )189190                f.error?.let {191                    Spacer(Modifier.height(8.dp))192                    Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)193                }194                Spacer(Modifier.height(40.dp))195                }196            }197        }198    }199}200201@Composable202private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {203    val accent = LocalPeachAccent.current204    Box(205        modifier = Modifier206            .clip(RoundedCornerShape(PeachRadius.TypeTab))207            .border(208                1.dp,209                if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,210                RoundedCornerShape(PeachRadius.TypeTab),211            )212            .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)213            .clickable(onClick = onClick)214            .padding(horizontal = 12.dp, vertical = 5.dp),215    ) {216        Text(217            text = type.uppercase(),218            color = if (selected) accent else peachColors.T2,219            fontFamily = PeachType.Mono,220            fontSize = 9.sp,221            letterSpacing = 0.08.em,222        )223    }224}225226/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */227internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {228    !isNew -> "edit"229    type == "note" -> "new note"230    type == "card" -> "new card"231    else -> "new entry"232}233234internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {235    saving -> "saving..."236    isNew -> "create"237    else -> "save"238}239240internal fun canSaveEditForm(form: EditForm): Boolean {241    if (form.title.isBlank()) return false242    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false243    return true244}245246/** A brand-new note opens the full-screen notes editor instead of the form. */247internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"248249/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */250internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f251252internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =253    type == "login" && (revealed || totp.isNotBlank())254255internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =256    type == "login" && (revealed || notes.isNotBlank())257258internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =259    type == "login" && !shouldShowLoginTotp(type, revealed, totp)260261internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =262    type == "login" && !shouldShowLoginNotes(type, revealed, notes)263264@Composable265private fun OptionalLoginAddLinks(266    showTotpLink: Boolean,267    showNotesLink: Boolean,268    onAddTotp: () -> Unit,269    onAddNotes: () -> Unit,270) {271    if (!showTotpLink && !showNotesLink) return272    Row(273        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),274        horizontalArrangement = Arrangement.spacedBy(14.dp),275    ) {276        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)277        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)278    }279}280281@Composable282private fun AddFieldLink(label: String, onClick: () -> Unit) {283    Text(284        label,285        color = LocalPeachAccent.current,286        fontFamily = PeachType.Mono,287        fontSize = 11.sp,288        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),289    )290}291292@Composable293private fun Field(294    label: String,295    value: String,296    onChange: (String) -> Unit,297    singleLine: Boolean = true,298    transformation: VisualTransformation = VisualTransformation.None,299    trailing: (@Composable () -> Unit)? = null,300) {301    PeachField(302        label = label,303        value = value,304        onChange = onChange,305        singleLine = singleLine,306        mono = true,307        visualTransformation = transformation,308        trailing = trailing,309    )310}311312@Composable313private fun PasswordField(314    value: String,315    show: Boolean,316    onChange: (String) -> Unit,317    onToggleShow: () -> Unit,318    onToggleGenerator: () -> Unit,319) {320    Field(321        label = "password",322        value = value,323        onChange = onChange,324        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),325        trailing = {326            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,327                fontFamily = PeachType.Mono, fontSize = 9.sp,328                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))329            Text("⟳", color = LocalPeachAccent.current, fontSize = 16.sp,330                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))331        },332    )333}334335@Composable336private fun GeneratorDrawer(337    open: Boolean,338    length: Int,339    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,340    password: String,341    copied: Boolean,342    onLength: (Int) -> Unit,343    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,344    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,345    onGenerate: () -> Unit,346    onCopy: () -> Unit,347) {348    val height by animateDpAsState(349        targetValue = if (open) 234.dp else 0.dp,350        animationSpec = tween(320, easing = PeachMotion.Spring),351        label = "gen-drawer",352    )353    Box(354        Modifier.fillMaxWidth().height(height).clipToBounds()355            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),356    ) {357        GeneratorBody(358            length = length,359            upper = upper, lower = lower, numbers = numbers, symbols = symbols,360            password = password, copied = copied,361            onLength = onLength,362            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,363            onGenerate = onGenerate, onCopy = onCopy,364        )365    }366    if (open) Spacer(Modifier.height(14.dp))367}368369/**370 * Shared generator body — used by the inline drawer here and the detail371 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.372 */373@Composable374internal fun GeneratorBody(375    length: Int,376    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,377    password: String,378    copied: Boolean,379    onLength: (Int) -> Unit,380    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,381    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,382    onGenerate: () -> Unit,383    onCopy: () -> Unit,384) {385    val accent = LocalPeachAccent.current386    Column(Modifier.fillMaxWidth().padding(16.dp)) {387        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {388            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)389            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)390        }391        Slider(392            value = length.toFloat(),393            onValueChange = { onLength(it.toInt()) },394            valueRange = 8f..128f,395            colors = SliderDefaults.colors(396                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,397            ),398        )399        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {400            TogglePill("A-Z", upper, onUpper)401            TogglePill("a-z", lower, onLower)402            TogglePill("0-9", numbers, onNumbers)403            TogglePill("!@#", symbols, onSymbols)404        }405        Spacer(Modifier.height(12.dp))406        Row(verticalAlignment = Alignment.CenterVertically) {407            Box(408                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))409                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))410                    .background(accent.copy(alpha = 0.10f))411                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),412                contentAlignment = Alignment.Center,413            ) {414                Text("generate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)415            }416            Spacer(Modifier.width(8.dp))417            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })418        }419    }420}421422@Composable423private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {424    val accent = LocalPeachAccent.current425    Box(426        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))427            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))428            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)429            .clickable { onToggle(!on) }430            .padding(horizontal = 10.dp, vertical = 6.dp),431    ) {432        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)433    }434}435436// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted437// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.