EntryEditScreen.kt

20.3 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.text.style.TextOverflow42import androidx.compose.ui.unit.dp43import androidx.compose.ui.unit.em44import androidx.compose.ui.unit.sp45import androidx.compose.runtime.collectAsState46import androidx.hilt.navigation.compose.hiltViewModel47import com.peachpasswords.ui.components.CopyButton48import com.peachpasswords.ui.theme.LocalPeachAccent49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.ui.theme.PeachMotion51import com.peachpasswords.ui.theme.PeachRadius52import com.peachpasswords.ui.theme.PeachType53import com.peachpasswords.ui.theme.ShellBackground5455@Composable56fun EntryEditScreen(57    id: String?,58    onBack: () -> Unit,59    onOpenNote: (String) -> Unit = {},60    vm: EntryEditViewModel = hiltViewModel(),61) {62    val f by vm.form.collectAsState()63    val usernameSuggestions by vm.usernameSuggestions.collectAsState()64    val emailSuggestions by vm.emailSuggestions.collectAsState()65    val accent = LocalPeachAccent.current6667    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.68    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(69        targetValue = editGhostWatermarkTarget(f.title),70        animationSpec = tween(300),71        label = "edit-watermark",72    )7374    ShellBackground {75        Column(Modifier.fillMaxSize().statusBarsPadding()) {76            Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {77                val canSave = canSaveEditForm(f)78                Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,79                    modifier = Modifier.align(Alignment.CenterStart)80                        .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))81                Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,82                    fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,83                    modifier = Modifier.align(Alignment.Center))84                Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,85                    modifier = Modifier.align(Alignment.CenterEnd)86                        .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))87            }8889            Box(Modifier.fillMaxSize().clipToBounds()) {90                // Ghost PeachScript watermark behind the form (matches the detail hero):91                // the full title, fading in as the user types.92                if (watermarkAlpha > 0f) {93                    Text(94                        text = ghostWatermarkText(f.title),95                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),96                        color = peachColors.T0.copy(alpha = watermarkAlpha),97                        maxLines = 1,98                        softWrap = false,99                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,100                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),101                    )102                }103                Column(104                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),105                ) {106                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {107                    listOf("login", "note", "card").forEach { t ->108                        EntryTypePill(t, f.type == t) {109                            // A brand-new note skips the form entirely and drops straight110                            // into the full-screen notes editor (brief Fix 6 item 7).111                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)112                            else vm.setType(t)113                        }114                    }115                }116                Spacer(Modifier.height(18.dp))117118                Field("title", f.title, vm::setTitle)119                if (f.type == "login") {120                    Field("url", f.url, vm::setUrl)121                    AdditionalUrlsEditor(122                        urls = f.additionalUrls,123                        onChange = vm::setAdditionalUrl,124                        onAdd = vm::addAdditionalUrl,125                        onRemove = vm::removeAdditionalUrl,126                    )127                    IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)128                    IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)129                    PasswordField(130                        value = f.password,131                        show = f.showPassword,132                        generatorOpen = f.generatorOpen,133                        onChange = vm::setPassword,134                        onToggleShow = vm::toggleShowPassword,135                        onToggleGenerator = vm::toggleGenerator,136                    )137                    GeneratorDrawer(138                        open = f.generatorOpen,139                        length = f.genLength,140                        upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,141                        password = f.password,142                        copied = f.genCopied,143                        onLength = vm::setGenLength,144                        onUpper = vm::setGenUpper, onLower = vm::setGenLower,145                        onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,146                        onGenerate = vm::generate,147                        onCopy = vm::copyGenerated,148                        onUse = vm::useGeneratedPassword,149                    )150                    PasswordHistorySection(151                        history = f.history,152                        expanded = f.showHistory,153                        onToggle = vm::toggleHistory,154                        onRestore = vm::restorePassword,155                        onCopy = vm::copyHistoryPassword,156                    )157                    if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {158                        Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)159                    }160                }161                if (f.type == "card") {162                    CardEditFields(163                        cardNickname = f.cardNickname,164                        cardNumber = f.cardNumber,165                        cardholderName = f.cardholderName,166                        expiry = f.expiry,167                        cvc = f.cvc,168                        showCvc = f.showCvc,169                        network = f.cardNetwork,170                        onCardNickname = vm::setCardNickname,171                        onCardNumber = vm::setCardNumber,172                        onCardholder = vm::setCardholderName,173                        onExpiry = vm::setExpiry,174                        onCvc = vm::setCvc,175                        onToggleShowCvc = vm::toggleShowCvc,176                    )177                }178                if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {179                    Field("notes", f.notes, vm::setNotes, singleLine = false)180                }181                if (f.type == "login") {182                    OptionalLoginAddLinks(183                        showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),184                        showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),185                        onAddTotp = vm::revealTotp,186                        onAddNotes = vm::revealNotes,187                    )188                }189                TagChipEditor(190                    tags = f.tags,191                    allTags = vm.allTags,192                    onAdd = vm::addTag,193                    onRemove = vm::removeTag,194                )195196                f.error?.let {197                    Spacer(Modifier.height(8.dp))198                    Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)199                }200                Spacer(Modifier.height(40.dp))201                }202            }203        }204    }205}206207@Composable208private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {209    val accent = LocalPeachAccent.current210    Box(211        modifier = Modifier212            .clip(RoundedCornerShape(PeachRadius.TypeTab))213            .border(214                1.dp,215                if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,216                RoundedCornerShape(PeachRadius.TypeTab),217            )218            .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)219            .clickable(onClick = onClick)220            .padding(horizontal = 12.dp, vertical = 5.dp),221    ) {222        Text(223            text = type.uppercase(),224            color = if (selected) accent else peachColors.T2,225            fontFamily = PeachType.Mono,226            fontSize = 9.sp,227            letterSpacing = 0.08.em,228        )229    }230}231232/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */233internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {234    !isNew -> "edit"235    type == "note" -> "new note"236    type == "card" -> "new card"237    else -> "new entry"238}239240internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {241    saving -> "saving..."242    isNew -> "create"243    else -> "save"244}245246internal fun canSaveEditForm(form: EditForm): Boolean {247    if (form.title.isBlank()) return false248    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false249    return true250}251252/** A brand-new note opens the full-screen notes editor instead of the form. */253internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"254255/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */256internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f257258internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =259    type == "login" && (revealed || totp.isNotBlank())260261internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =262    type == "login" && (revealed || notes.isNotBlank())263264internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =265    type == "login" && !shouldShowLoginTotp(type, revealed, totp)266267internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =268    type == "login" && !shouldShowLoginNotes(type, revealed, notes)269270@Composable271private fun OptionalLoginAddLinks(272    showTotpLink: Boolean,273    showNotesLink: Boolean,274    onAddTotp: () -> Unit,275    onAddNotes: () -> Unit,276) {277    if (!showTotpLink && !showNotesLink) return278    Row(279        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),280        horizontalArrangement = Arrangement.spacedBy(14.dp),281    ) {282        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)283        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)284    }285}286287@Composable288private fun AddFieldLink(label: String, onClick: () -> Unit) {289    Text(290        label,291        color = LocalPeachAccent.current,292        fontFamily = PeachType.Mono,293        fontSize = 11.sp,294        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),295    )296}297298@Composable299private fun Field(300    label: String,301    value: String,302    onChange: (String) -> Unit,303    singleLine: Boolean = true,304    transformation: VisualTransformation = VisualTransformation.None,305    trailing: (@Composable () -> Unit)? = null,306) {307    PeachField(308        label = label,309        value = value,310        onChange = onChange,311        singleLine = singleLine,312        mono = true,313        visualTransformation = transformation,314        trailing = trailing,315    )316}317318@Composable319private fun PasswordField(320    value: String,321    show: Boolean,322    generatorOpen: Boolean,323    onChange: (String) -> Unit,324    onToggleShow: () -> Unit,325    onToggleGenerator: () -> Unit,326) {327    Field(328        label = "password",329        value = value,330        onChange = onChange,331        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),332        trailing = {333            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,334                fontFamily = PeachType.Mono, fontSize = 9.sp,335                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))336            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,337                fontFamily = PeachType.Mono, fontSize = 9.sp,338                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))339        },340    )341}342343internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"344345@Composable346private fun GeneratorDrawer(347    open: Boolean,348    length: Int,349    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,350    password: String,351    copied: Boolean,352    onLength: (Int) -> Unit,353    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,354    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,355    onGenerate: () -> Unit,356    onCopy: () -> Unit,357    onUse: () -> Unit,358) {359    val height by animateDpAsState(360        targetValue = if (open) 326.dp else 0.dp,361        animationSpec = tween(320, easing = PeachMotion.Spring),362        label = "gen-drawer",363    )364    Box(365        Modifier.fillMaxWidth().height(height).clipToBounds()366            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),367    ) {368        GeneratorBody(369            length = length,370            upper = upper, lower = lower, numbers = numbers, symbols = symbols,371            password = password, copied = copied,372            onLength = onLength,373            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,374            onGenerate = onGenerate, onCopy = onCopy,375            showOutput = true,376            onUse = onUse,377        )378    }379    if (open) Spacer(Modifier.height(14.dp))380}381382/**383 * Shared generator body — used by the inline drawer here and the detail384 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.385 */386@Composable387internal fun GeneratorBody(388    length: Int,389    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,390    password: String,391    copied: Boolean,392    onLength: (Int) -> Unit,393    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,394    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,395    onGenerate: () -> Unit,396    onCopy: () -> Unit,397    showOutput: Boolean = false,398    onUse: (() -> Unit)? = null,399) {400    val accent = LocalPeachAccent.current401    Column(Modifier.fillMaxWidth().padding(16.dp)) {402        if (showOutput) {403            Box(404                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))405                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))406                    .background(accent.copy(alpha = 0.08f))407                    .padding(horizontal = 12.dp, vertical = 10.dp),408            ) {409                Text(410                    text = password.ifEmpty { "tap generate" },411                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,412                    fontFamily = PeachType.Mono,413                    fontSize = 11.sp,414                    maxLines = 2,415                    overflow = TextOverflow.Ellipsis,416                )417            }418            Spacer(Modifier.height(10.dp))419        }420        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {421            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)422            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)423        }424        Slider(425            value = length.toFloat(),426            onValueChange = { onLength(it.toInt()) },427            valueRange = 8f..128f,428            colors = SliderDefaults.colors(429                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,430            ),431        )432        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {433            TogglePill("A-Z", upper, onUpper)434            TogglePill("a-z", lower, onLower)435            TogglePill("0-9", numbers, onNumbers)436            TogglePill("!@#", symbols, onSymbols)437        }438        Spacer(Modifier.height(12.dp))439        Row(verticalAlignment = Alignment.CenterVertically) {440            Box(441                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))442                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))443                    .background(accent.copy(alpha = 0.10f))444                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),445                contentAlignment = Alignment.Center,446            ) {447                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)448            }449            Spacer(Modifier.width(8.dp))450            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })451        }452        onUse?.let {453            Spacer(Modifier.height(10.dp))454            Box(455                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))456                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))457                    .background(accent.copy(alpha = 0.14f))458                    .clickable(enabled = password.isNotEmpty(), onClick = it)459                    .padding(vertical = 12.dp),460                contentAlignment = Alignment.Center,461            ) {462                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)463            }464        }465    }466}467468@Composable469private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {470    val accent = LocalPeachAccent.current471    Box(472        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))473            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))474            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)475            .clickable { onToggle(!on) }476            .padding(horizontal = 10.dp, vertical = 6.dp),477    ) {478        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)479    }480}481482// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted483// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.