EntryEditScreen.kt

21.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.FontWeight38import androidx.compose.ui.text.input.PasswordVisualTransformation39import androidx.compose.ui.text.input.VisualTransformation40import androidx.compose.ui.text.style.TextOverflow41import 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.components.PeachEmptyOrEndState48import com.peachpasswords.ui.components.PeachSegment49import com.peachpasswords.ui.components.PeachSegmentedControl50import com.peachpasswords.ui.components.PeachTopBar51import com.peachpasswords.ui.components.SecureFlagEffect52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.PeachMotion54import com.peachpasswords.ui.theme.PeachRadius55import com.peachpasswords.ui.theme.PeachSpacing56import com.peachpasswords.ui.theme.PeachType57import com.peachpasswords.ui.theme.ShellBackground58import com.peachpasswords.ui.theme.peachColors5960@Composable61fun EntryEditScreen(62    id: String?,63    onBack: () -> Unit,64    onOpenNote: (String) -> Unit = {},65    vm: EntryEditViewModel = hiltViewModel(),66) {67    SecureFlagEffect()68    val f by vm.form.collectAsState()69    val usernameSuggestions by vm.usernameSuggestions.collectAsState()70    val emailSuggestions by vm.emailSuggestions.collectAsState()7172    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.73    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(74        targetValue = editGhostWatermarkTarget(f.title),75        animationSpec = tween(300),76        label = "edit-watermark",77    )7879    ShellBackground {80        Column(81            Modifier82                .fillMaxSize()83                .statusBarsPadding()84                .padding(horizontal = PeachSpacing.xl),85        ) {86            EntryEditTopBar(87                title = editTitleBarLabel(vm.isNew, f.type),88                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),89                canSave = canSaveEditForm(f) && !f.saving,90                onCancel = onBack,91                onSave = { vm.save(onBack) },92            )9394            Spacer(Modifier.height(PeachSpacing.lg))9596            Box(Modifier.fillMaxSize().clipToBounds()) {97                // Ghost PeachScript watermark behind the form (matches the detail hero):98                // the full title, fading in as the user types.99                if (watermarkAlpha > 0f) {100                    Text(101                        text = ghostWatermarkText(f.title),102                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),103                        color = peachColors.T0.copy(alpha = watermarkAlpha),104                        maxLines = 1,105                        softWrap = false,106                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,107                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),108                    )109                }110                Column(111                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()),112                ) {113                    PeachSegmentedControl(114                        segments = listOf(115                            PeachSegment("login", "login"),116                            PeachSegment("note", "note"),117                            PeachSegment("card", "card"),118                        ),119                        selectedKey = f.type,120                        onSelected = { t ->121                            // A brand-new note skips the form entirely and drops straight122                            // into the full-screen notes editor (brief Fix 6 item 7).123                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)124                            else vm.setType(t)125                        },126                    )127                    Spacer(Modifier.height(PeachSpacing.lg))128129                    Field("title", f.title, vm::setTitle)130                    if (f.type == "login") {131                        Field("url", f.url, vm::setUrl)132                        AdditionalUrlsEditor(133                            urls = f.additionalUrls,134                            onChange = vm::setAdditionalUrl,135                            onAdd = vm::addAdditionalUrl,136                            onRemove = vm::removeAdditionalUrl,137                        )138                        IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)139                        IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)140                        PasswordField(141                            value = f.password,142                            show = f.showPassword,143                            generatorOpen = f.generatorOpen,144                            onChange = vm::setPassword,145                            onToggleShow = vm::toggleShowPassword,146                            onToggleGenerator = vm::toggleGenerator,147                        )148                        GeneratorDrawer(149                            open = f.generatorOpen,150                            length = f.genLength,151                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,152                            password = f.password,153                            copied = f.genCopied,154                            onLength = vm::setGenLength,155                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,156                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,157                            onGenerate = vm::generate,158                            onCopy = vm::copyGenerated,159                            onUse = vm::useGeneratedPassword,160                        )161                        PasswordHistorySection(162                            history = f.history,163                            expanded = f.showHistory,164                            onToggle = vm::toggleHistory,165                            onRestore = vm::restorePassword,166                            onCopy = vm::copyHistoryPassword,167                        )168                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {169                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)170                        }171                    }172                    if (f.type == "card") {173                        CardEditFields(174                            cardNickname = f.cardNickname,175                            cardNumber = f.cardNumber,176                            cardholderName = f.cardholderName,177                            expiry = f.expiry,178                            cvc = f.cvc,179                            showCvc = f.showCvc,180                            network = f.cardNetwork,181                            onCardNickname = vm::setCardNickname,182                            onCardNumber = vm::setCardNumber,183                            onCardholder = vm::setCardholderName,184                            onExpiry = vm::setExpiry,185                            onCvc = vm::setCvc,186                            onToggleShowCvc = vm::toggleShowCvc,187                        )188                    }189                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {190                        Field("notes", f.notes, vm::setNotes, singleLine = false)191                    }192                    if (f.type == "login") {193                        OptionalLoginAddLinks(194                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),195                            showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),196                            onAddTotp = vm::revealTotp,197                            onAddNotes = vm::revealNotes,198                        )199                    }200                    TagChipEditor(201                        tags = f.tags,202                        allTags = vm.allTags,203                        onAdd = vm::addTag,204                        onRemove = vm::removeTag,205                    )206207                    f.error?.let {208                        Spacer(Modifier.height(PeachSpacing.sm))209                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)210                    }211                    PeachEmptyOrEndState(212                        message = editEndStateMessage(f.type),213                        detail = "Review the fields above, then save from the top bar.",214                        modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.xl),215                    )216                }217            }218        }219    }220}221222@Composable223private fun EntryEditTopBar(224    title: String,225    saveLabel: String,226    canSave: Boolean,227    onCancel: () -> Unit,228    onSave: () -> Unit,229) {230    val accent = LocalPeachAccent.current231    PeachTopBar(232        title = title,233        leading = {234            Text(235                "cancel",236                color = peachColors.T2,237                fontFamily = PeachType.Mono,238                fontSize = 11.sp,239                modifier = Modifier240                    .clip(RoundedCornerShape(8.dp))241                    .clickable(onClick = onCancel)242                    .padding(PeachSpacing.sm),243            )244        },245        actions = {246            Text(247                saveLabel,248                color = if (canSave) accent else peachColors.T2,249                fontFamily = PeachType.Mono,250                fontSize = 11.sp,251                modifier = Modifier252                    .clip(RoundedCornerShape(8.dp))253                    .clickable(enabled = canSave, onClick = onSave)254                    .padding(PeachSpacing.sm),255            )256        },257    )258}259260/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */261internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {262    !isNew -> "edit"263    type == "note" -> "new note"264    type == "card" -> "new card"265    else -> "new entry"266}267268internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {269    saving -> "saving..."270    isNew -> "create"271    else -> "save"272}273274internal fun canSaveEditForm(form: EditForm): Boolean {275    if (form.title.isBlank()) return false276    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false277    return true278}279280/** A brand-new note opens the full-screen notes editor instead of the form. */281internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"282283/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */284internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f285286internal fun editEndStateMessage(type: String): String = when (type) {287    "card" -> "card details ready"288    "note" -> "note details ready"289    else -> "login details ready"290}291292internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =293    type == "login" && (revealed || totp.isNotBlank())294295internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =296    type == "login" && (revealed || notes.isNotBlank())297298internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =299    type == "login" && !shouldShowLoginTotp(type, revealed, totp)300301internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =302    type == "login" && !shouldShowLoginNotes(type, revealed, notes)303304@Composable305private fun OptionalLoginAddLinks(306    showTotpLink: Boolean,307    showNotesLink: Boolean,308    onAddTotp: () -> Unit,309    onAddNotes: () -> Unit,310) {311    if (!showTotpLink && !showNotesLink) return312    Row(313        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),314        horizontalArrangement = Arrangement.spacedBy(14.dp),315    ) {316        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)317        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)318    }319}320321@Composable322private fun AddFieldLink(label: String, onClick: () -> Unit) {323    Text(324        label,325        color = LocalPeachAccent.current,326        fontFamily = PeachType.Mono,327        fontSize = 11.sp,328        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),329    )330}331332@Composable333private fun Field(334    label: String,335    value: String,336    onChange: (String) -> Unit,337    singleLine: Boolean = true,338    transformation: VisualTransformation = VisualTransformation.None,339    trailing: (@Composable () -> Unit)? = null,340) {341    PeachField(342        label = label,343        value = value,344        onChange = onChange,345        singleLine = singleLine,346        mono = true,347        visualTransformation = transformation,348        trailing = trailing,349    )350}351352@Composable353private fun PasswordField(354    value: String,355    show: Boolean,356    generatorOpen: Boolean,357    onChange: (String) -> Unit,358    onToggleShow: () -> Unit,359    onToggleGenerator: () -> Unit,360) {361    Field(362        label = "password",363        value = value,364        onChange = onChange,365        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),366        trailing = {367            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,368                fontFamily = PeachType.Mono, fontSize = 9.sp,369                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))370            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,371                fontFamily = PeachType.Mono, fontSize = 9.sp,372                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))373        },374    )375}376377internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"378379@Composable380private fun GeneratorDrawer(381    open: Boolean,382    length: Int,383    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,384    password: String,385    copied: Boolean,386    onLength: (Int) -> Unit,387    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,388    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,389    onGenerate: () -> Unit,390    onCopy: () -> Unit,391    onUse: () -> Unit,392) {393    val height by animateDpAsState(394        targetValue = if (open) 326.dp else 0.dp,395        animationSpec = tween(320, easing = PeachMotion.Spring),396        label = "gen-drawer",397    )398    Box(399        Modifier.fillMaxWidth().height(height).clipToBounds()400            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),401    ) {402        GeneratorBody(403            length = length,404            upper = upper, lower = lower, numbers = numbers, symbols = symbols,405            password = password, copied = copied,406            onLength = onLength,407            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,408            onGenerate = onGenerate, onCopy = onCopy,409            showOutput = true,410            onUse = onUse,411        )412    }413    if (open) Spacer(Modifier.height(14.dp))414}415416/**417 * Shared generator body — used by the inline drawer here and the detail418 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.419 */420@Composable421internal fun GeneratorBody(422    length: Int,423    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,424    password: String,425    copied: Boolean,426    onLength: (Int) -> Unit,427    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,428    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,429    onGenerate: () -> Unit,430    onCopy: () -> Unit,431    showOutput: Boolean = false,432    onUse: (() -> Unit)? = null,433) {434    val accent = LocalPeachAccent.current435    Column(Modifier.fillMaxWidth().padding(16.dp)) {436        if (showOutput) {437            Box(438                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))439                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))440                    .background(accent.copy(alpha = 0.08f))441                    .padding(horizontal = 12.dp, vertical = 10.dp),442            ) {443                Text(444                    text = password.ifEmpty { "tap generate" },445                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,446                    fontFamily = PeachType.Mono,447                    fontSize = 11.sp,448                    maxLines = 2,449                    overflow = TextOverflow.Ellipsis,450                )451            }452            Spacer(Modifier.height(10.dp))453        }454        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {455            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)456            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)457        }458        Slider(459            value = length.toFloat(),460            onValueChange = { onLength(it.toInt()) },461            valueRange = 8f..128f,462            colors = SliderDefaults.colors(463                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,464            ),465        )466        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {467            TogglePill("A-Z", upper, onUpper)468            TogglePill("a-z", lower, onLower)469            TogglePill("0-9", numbers, onNumbers)470            TogglePill("!@#", symbols, onSymbols)471        }472        Spacer(Modifier.height(12.dp))473        Row(verticalAlignment = Alignment.CenterVertically) {474            Box(475                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))476                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))477                    .background(accent.copy(alpha = 0.10f))478                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),479                contentAlignment = Alignment.Center,480            ) {481                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)482            }483            Spacer(Modifier.width(8.dp))484            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })485        }486        onUse?.let {487            Spacer(Modifier.height(10.dp))488            Box(489                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))490                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))491                    .background(accent.copy(alpha = 0.14f))492                    .clickable(enabled = password.isNotEmpty(), onClick = it)493                    .padding(vertical = 12.dp),494                contentAlignment = Alignment.Center,495            ) {496                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)497            }498        }499    }500}501502@Composable503private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {504    val accent = LocalPeachAccent.current505    Box(506        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))507            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))508            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)509            .clickable { onToggle(!on) }510            .padding(horizontal = 10.dp, vertical = 6.dp),511    ) {512        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)513    }514}515516// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted517// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.