EntryEditScreen.kt

21.1 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.BorderStroke4import androidx.compose.animation.core.animateDpAsState5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Row13import androidx.compose.foundation.layout.Spacer14import androidx.compose.foundation.layout.fillMaxSize15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.height17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.navigationBarsPadding19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.statusBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.rememberScrollState23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.verticalScroll25import androidx.compose.material3.Slider26import androidx.compose.material3.SliderDefaults27import androidx.compose.material3.Surface28import androidx.compose.material3.Text29import androidx.compose.runtime.Composable30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.setValue34import androidx.compose.ui.Alignment35import androidx.compose.ui.Modifier36import androidx.compose.ui.draw.clip37import androidx.compose.ui.draw.clipToBounds38import androidx.compose.ui.graphics.Color39import androidx.compose.ui.text.TextStyle40import androidx.compose.ui.text.font.FontWeight41import androidx.compose.ui.text.input.PasswordVisualTransformation42import androidx.compose.ui.text.input.VisualTransformation43import androidx.compose.ui.text.style.TextAlign44import androidx.compose.ui.text.style.TextOverflow45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.em47import androidx.compose.ui.unit.sp48import androidx.compose.runtime.collectAsState49import androidx.hilt.navigation.compose.hiltViewModel50import com.peachpasswords.ui.components.CopyButton51import com.peachpasswords.ui.components.PeachEmptyOrEndState52import com.peachpasswords.ui.components.PeachSegment53import com.peachpasswords.ui.components.PeachSegmentedControl54import com.peachpasswords.ui.components.SecureFlagEffect55import com.peachpasswords.ui.theme.LocalPeachAccent56import com.peachpasswords.ui.theme.PeachMotion57import com.peachpasswords.ui.theme.PeachRadius58import com.peachpasswords.ui.theme.PeachSpacing59import com.peachpasswords.ui.theme.PeachType60import com.peachpasswords.ui.theme.ShellBackground61import com.peachpasswords.ui.theme.peachColors6263@Composable64fun EntryEditScreen(65    id: String?,66    onBack: () -> Unit,67    onHome: () -> Unit,68    onOpenNote: (String) -> Unit = {},69    vm: EntryEditViewModel = hiltViewModel(),70) {71    SecureFlagEffect()7273    val f by vm.form.collectAsState()74    val usernameSuggestions by vm.usernameSuggestions.collectAsState()75    val emailSuggestions by vm.emailSuggestions.collectAsState()7677    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.78    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(79        targetValue = editGhostWatermarkTarget(f.title),80        animationSpec = tween(300),81        label = "edit-watermark",82    )8384    ShellBackground {85        Column(86            Modifier87                .fillMaxSize()88                .statusBarsPadding()89                .padding(horizontal = PeachSpacing.xl),90        ) {91            PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type), onHome)9293            Spacer(Modifier.height(PeachSpacing.lg))9495            Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) {96                // Ghost PeachScript watermark behind the form (matches the detail hero):97                // the full title, fading in as the user types.98                if (watermarkAlpha > 0f) {99                    Text(100                        text = ghostWatermarkText(f.title),101                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),102                        color = peachColors.T0.copy(alpha = watermarkAlpha),103                        maxLines = 1,104                        softWrap = false,105                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,106                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),107                    )108                }109                Column(110                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()),111                ) {112                    PeachSegmentedControl(113                        segments = listOf(114                            PeachSegment("login", "login"),115                            PeachSegment("note", "note"),116                            PeachSegment("card", "card"),117                        ),118                        selectedKey = f.type,119                        onSelected = { t ->120                            // A brand-new note skips the form entirely and drops straight121                            // into the full-screen notes editor (brief Fix 6 item 7).122                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)123                            else vm.setType(t)124                        },125                    )126                    Spacer(Modifier.height(PeachSpacing.lg))127128                    Field("title", f.title, vm::setTitle)129                    if (f.type == "login") {130                        Field("url", f.url, vm::setUrl)131                        AdditionalUrlsEditor(132                            urls = f.additionalUrls,133                            onChange = vm::setAdditionalUrl,134                            onAdd = vm::addAdditionalUrl,135                            onRemove = vm::removeAdditionalUrl,136                        )137                        IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)138                        IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)139                        PasswordField(140                            value = f.password,141                            show = f.showPassword,142                            generatorOpen = f.generatorOpen,143                            onChange = vm::setPassword,144                            onToggleShow = vm::toggleShowPassword,145                            onToggleGenerator = vm::toggleGenerator,146                        )147                        GeneratorDrawer(148                            open = f.generatorOpen,149                            length = f.genLength,150                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,151                            password = f.password,152                            copied = f.genCopied,153                            onLength = vm::setGenLength,154                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,155                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,156                            onGenerate = vm::generate,157                            onCopy = vm::copyGenerated,158                            onUse = vm::useGeneratedPassword,159                        )160                        PasswordHistorySection(161                            history = f.history,162                            expanded = f.showHistory,163                            onToggle = vm::toggleHistory,164                            onRestore = vm::restorePassword,165                            onCopy = vm::copyHistoryPassword,166                        )167                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {168                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)169                        }170                    }171                    if (f.type == "card") {172                        CardEditFields(173                            cardNickname = f.cardNickname,174                            cardNumber = f.cardNumber,175                            cardholderName = f.cardholderName,176                            expiry = f.expiry,177                            cvc = f.cvc,178                            showCvc = f.showCvc,179                            network = f.cardNetwork,180                            onCardNickname = vm::setCardNickname,181                            onCardNumber = vm::setCardNumber,182                            onCardholder = vm::setCardholderName,183                            onExpiry = vm::setExpiry,184                            onCvc = vm::setCvc,185                            onToggleShowCvc = vm::toggleShowCvc,186                        )187                    }188                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {189                        Field("notes", f.notes, vm::setNotes, singleLine = false)190                    }191                    if (f.type == "login") {192                        OptionalLoginAddLinks(193                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),194                            showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),195                            onAddTotp = vm::revealTotp,196                            onAddNotes = vm::revealNotes,197                        )198                    }199                    TagChipEditor(200                        tags = f.tags,201                        allTags = vm.allTags,202                        onAdd = vm::addTag,203                        onRemove = vm::removeTag,204                    )205206                    f.error?.let {207                        Spacer(Modifier.height(PeachSpacing.sm))208                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)209                    }210                    Spacer(Modifier.height(PeachSpacing.xl))211                }212            }213            EntryEditBottomActions(214                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),215                canSave = canSaveEditForm(f) && !f.saving,216                onCancel = onBack,217                onSave = { vm.save(onBack) },218            )219        }220    }221}222223@Composable224private fun EntryEditBottomActions(225    saveLabel: String,226    canSave: Boolean,227    onCancel: () -> Unit,228    onSave: () -> Unit,229) {230    val accent = LocalPeachAccent.current231    Row(232        Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),233        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),234    ) {235        Surface(236            modifier = Modifier.weight(1f).clickable(onClick = onCancel),237            color = peachColors.S1,238            shape = RoundedCornerShape(12.dp),239            border = BorderStroke(1.dp, peachColors.T3),240        ) {241            Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)242        }243        Surface(244            modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),245            color = if (canSave) accent else peachColors.S2,246            shape = RoundedCornerShape(12.dp),247        ) {248            Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)249        }250    }251}252253/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */254internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {255    !isNew -> "edit"256    type == "note" -> "new note"257    type == "card" -> "new card"258    else -> "new entry"259}260261internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {262    saving -> "saving..."263    isNew -> "create"264    else -> "save"265}266267internal fun canSaveEditForm(form: EditForm): Boolean {268    if (form.title.isBlank()) return false269    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false270    return true271}272273/** A brand-new note opens the full-screen notes editor instead of the form. */274internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"275276/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */277internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f278279internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =280    type == "login" && (revealed || totp.isNotBlank())281282internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =283    type == "login" && (revealed || notes.isNotBlank())284285internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =286    type == "login" && !shouldShowLoginTotp(type, revealed, totp)287288internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =289    type == "login" && !shouldShowLoginNotes(type, revealed, notes)290291@Composable292private fun OptionalLoginAddLinks(293    showTotpLink: Boolean,294    showNotesLink: Boolean,295    onAddTotp: () -> Unit,296    onAddNotes: () -> Unit,297) {298    if (!showTotpLink && !showNotesLink) return299    Row(300        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),301        horizontalArrangement = Arrangement.spacedBy(14.dp),302    ) {303        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)304        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)305    }306}307308@Composable309private fun AddFieldLink(label: String, onClick: () -> Unit) {310    Text(311        label,312        color = LocalPeachAccent.current,313        fontFamily = PeachType.Mono,314        fontSize = 11.sp,315        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),316    )317}318319@Composable320private fun Field(321    label: String,322    value: String,323    onChange: (String) -> Unit,324    singleLine: Boolean = true,325    transformation: VisualTransformation = VisualTransformation.None,326    trailing: (@Composable () -> Unit)? = null,327) {328    PeachField(329        label = label,330        value = value,331        onChange = onChange,332        singleLine = singleLine,333        mono = true,334        visualTransformation = transformation,335        trailing = trailing,336    )337}338339@Composable340private fun PasswordField(341    value: String,342    show: Boolean,343    generatorOpen: Boolean,344    onChange: (String) -> Unit,345    onToggleShow: () -> Unit,346    onToggleGenerator: () -> Unit,347) {348    Field(349        label = "password",350        value = value,351        onChange = onChange,352        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),353        trailing = {354            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,355                fontFamily = PeachType.Mono, fontSize = 9.sp,356                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))357            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,358                fontFamily = PeachType.Mono, fontSize = 9.sp,359                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))360        },361    )362}363364internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"365366@Composable367private fun GeneratorDrawer(368    open: Boolean,369    length: Int,370    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,371    password: String,372    copied: Boolean,373    onLength: (Int) -> Unit,374    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,375    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,376    onGenerate: () -> Unit,377    onCopy: () -> Unit,378    onUse: () -> Unit,379) {380    val height by animateDpAsState(381        targetValue = if (open) 326.dp else 0.dp,382        animationSpec = tween(320, easing = PeachMotion.Spring),383        label = "gen-drawer",384    )385    Box(386        Modifier.fillMaxWidth().height(height).clipToBounds()387            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),388    ) {389        GeneratorBody(390            length = length,391            upper = upper, lower = lower, numbers = numbers, symbols = symbols,392            password = password, copied = copied,393            onLength = onLength,394            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,395            onGenerate = onGenerate, onCopy = onCopy,396            showOutput = true,397            onUse = onUse,398        )399    }400    if (open) Spacer(Modifier.height(14.dp))401}402403/**404 * Shared generator body — used by the inline drawer here and the detail405 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.406 */407@Composable408internal fun GeneratorBody(409    length: Int,410    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,411    password: String,412    copied: Boolean,413    onLength: (Int) -> Unit,414    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,415    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,416    onGenerate: () -> Unit,417    onCopy: () -> Unit,418    showOutput: Boolean = false,419    onUse: (() -> Unit)? = null,420) {421    val accent = LocalPeachAccent.current422    Column(Modifier.fillMaxWidth().padding(16.dp)) {423        if (showOutput) {424            Box(425                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))426                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))427                    .background(accent.copy(alpha = 0.08f))428                    .padding(horizontal = 12.dp, vertical = 10.dp),429            ) {430                Text(431                    text = password.ifEmpty { "tap generate" },432                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,433                    fontFamily = PeachType.Mono,434                    fontSize = 11.sp,435                    maxLines = 2,436                    overflow = TextOverflow.Ellipsis,437                )438            }439            Spacer(Modifier.height(10.dp))440        }441        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {442            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)443            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)444        }445        Slider(446            value = length.toFloat(),447            onValueChange = { onLength(it.toInt()) },448            valueRange = 8f..128f,449            colors = SliderDefaults.colors(450                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,451            ),452        )453        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {454            TogglePill("A-Z", upper, onUpper)455            TogglePill("a-z", lower, onLower)456            TogglePill("0-9", numbers, onNumbers)457            TogglePill("!@#", symbols, onSymbols)458        }459        Spacer(Modifier.height(12.dp))460        Row(verticalAlignment = Alignment.CenterVertically) {461            Box(462                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))463                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))464                    .background(accent.copy(alpha = 0.10f))465                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),466                contentAlignment = Alignment.Center,467            ) {468                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)469            }470            Spacer(Modifier.width(8.dp))471            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })472        }473        onUse?.let {474            Spacer(Modifier.height(10.dp))475            Box(476                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))477                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))478                    .background(accent.copy(alpha = 0.14f))479                    .clickable(enabled = password.isNotEmpty(), onClick = it)480                    .padding(vertical = 12.dp),481                contentAlignment = Alignment.Center,482            ) {483                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)484            }485        }486    }487}488489@Composable490private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {491    val accent = LocalPeachAccent.current492    Box(493        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))494            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))495            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)496            .clickable { onToggle(!on) }497            .padding(horizontal = 10.dp, vertical = 6.dp),498    ) {499        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)500    }501}502503// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted504// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.