EntryEditScreen.kt

21.0 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    onOpenNote: (String) -> Unit = {},68    vm: EntryEditViewModel = hiltViewModel(),69) {70    SecureFlagEffect()7172    val f by vm.form.collectAsState()73    val usernameSuggestions by vm.usernameSuggestions.collectAsState()74    val emailSuggestions by vm.emailSuggestions.collectAsState()7576    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.77    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(78        targetValue = editGhostWatermarkTarget(f.title),79        animationSpec = tween(300),80        label = "edit-watermark",81    )8283    ShellBackground {84        Column(85            Modifier86                .fillMaxSize()87                .statusBarsPadding()88                .padding(horizontal = PeachSpacing.xl),89        ) {90            PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type))9192            Spacer(Modifier.height(PeachSpacing.lg))9394            Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) {95                // Ghost PeachScript watermark behind the form (matches the detail hero):96                // the full title, fading in as the user types.97                if (watermarkAlpha > 0f) {98                    Text(99                        text = ghostWatermarkText(f.title),100                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),101                        color = peachColors.T0.copy(alpha = watermarkAlpha),102                        maxLines = 1,103                        softWrap = false,104                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,105                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),106                    )107                }108                Column(109                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()),110                ) {111                    PeachSegmentedControl(112                        segments = listOf(113                            PeachSegment("login", "login"),114                            PeachSegment("note", "note"),115                            PeachSegment("card", "card"),116                        ),117                        selectedKey = f.type,118                        onSelected = { t ->119                            // A brand-new note skips the form entirely and drops straight120                            // into the full-screen notes editor (brief Fix 6 item 7).121                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)122                            else vm.setType(t)123                        },124                    )125                    Spacer(Modifier.height(PeachSpacing.lg))126127                    Field("title", f.title, vm::setTitle)128                    if (f.type == "login") {129                        Field("url", f.url, vm::setUrl)130                        AdditionalUrlsEditor(131                            urls = f.additionalUrls,132                            onChange = vm::setAdditionalUrl,133                            onAdd = vm::addAdditionalUrl,134                            onRemove = vm::removeAdditionalUrl,135                        )136                        IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)137                        IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)138                        PasswordField(139                            value = f.password,140                            show = f.showPassword,141                            generatorOpen = f.generatorOpen,142                            onChange = vm::setPassword,143                            onToggleShow = vm::toggleShowPassword,144                            onToggleGenerator = vm::toggleGenerator,145                        )146                        GeneratorDrawer(147                            open = f.generatorOpen,148                            length = f.genLength,149                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,150                            password = f.password,151                            copied = f.genCopied,152                            onLength = vm::setGenLength,153                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,154                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,155                            onGenerate = vm::generate,156                            onCopy = vm::copyGenerated,157                            onUse = vm::useGeneratedPassword,158                        )159                        PasswordHistorySection(160                            history = f.history,161                            expanded = f.showHistory,162                            onToggle = vm::toggleHistory,163                            onRestore = vm::restorePassword,164                            onCopy = vm::copyHistoryPassword,165                        )166                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {167                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)168                        }169                    }170                    if (f.type == "card") {171                        CardEditFields(172                            cardNickname = f.cardNickname,173                            cardNumber = f.cardNumber,174                            cardholderName = f.cardholderName,175                            expiry = f.expiry,176                            cvc = f.cvc,177                            showCvc = f.showCvc,178                            network = f.cardNetwork,179                            onCardNickname = vm::setCardNickname,180                            onCardNumber = vm::setCardNumber,181                            onCardholder = vm::setCardholderName,182                            onExpiry = vm::setExpiry,183                            onCvc = vm::setCvc,184                            onToggleShowCvc = vm::toggleShowCvc,185                        )186                    }187                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {188                        Field("notes", f.notes, vm::setNotes, singleLine = false)189                    }190                    if (f.type == "login") {191                        OptionalLoginAddLinks(192                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),193                            showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),194                            onAddTotp = vm::revealTotp,195                            onAddNotes = vm::revealNotes,196                        )197                    }198                    TagChipEditor(199                        tags = f.tags,200                        allTags = vm.allTags,201                        onAdd = vm::addTag,202                        onRemove = vm::removeTag,203                    )204205                    f.error?.let {206                        Spacer(Modifier.height(PeachSpacing.sm))207                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)208                    }209                    Spacer(Modifier.height(PeachSpacing.xl))210                }211            }212            EntryEditBottomActions(213                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),214                canSave = canSaveEditForm(f) && !f.saving,215                onCancel = onBack,216                onSave = { vm.save(onBack) },217            )218        }219    }220}221222@Composable223private fun EntryEditBottomActions(224    saveLabel: String,225    canSave: Boolean,226    onCancel: () -> Unit,227    onSave: () -> Unit,228) {229    val accent = LocalPeachAccent.current230    Row(231        Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),232        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),233    ) {234        Surface(235            modifier = Modifier.weight(1f).clickable(onClick = onCancel),236            color = peachColors.S1,237            shape = RoundedCornerShape(12.dp),238            border = BorderStroke(1.dp, peachColors.T3),239        ) {240            Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)241        }242        Surface(243            modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),244            color = if (canSave) accent else peachColors.S2,245            shape = RoundedCornerShape(12.dp),246        ) {247            Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)248        }249    }250}251252/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */253internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {254    !isNew -> "edit"255    type == "note" -> "new note"256    type == "card" -> "new card"257    else -> "new entry"258}259260internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {261    saving -> "saving..."262    isNew -> "create"263    else -> "save"264}265266internal fun canSaveEditForm(form: EditForm): Boolean {267    if (form.title.isBlank()) return false268    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false269    return true270}271272/** A brand-new note opens the full-screen notes editor instead of the form. */273internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"274275/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */276internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f277278internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =279    type == "login" && (revealed || totp.isNotBlank())280281internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =282    type == "login" && (revealed || notes.isNotBlank())283284internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =285    type == "login" && !shouldShowLoginTotp(type, revealed, totp)286287internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =288    type == "login" && !shouldShowLoginNotes(type, revealed, notes)289290@Composable291private fun OptionalLoginAddLinks(292    showTotpLink: Boolean,293    showNotesLink: Boolean,294    onAddTotp: () -> Unit,295    onAddNotes: () -> Unit,296) {297    if (!showTotpLink && !showNotesLink) return298    Row(299        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),300        horizontalArrangement = Arrangement.spacedBy(14.dp),301    ) {302        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)303        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)304    }305}306307@Composable308private fun AddFieldLink(label: String, onClick: () -> Unit) {309    Text(310        label,311        color = LocalPeachAccent.current,312        fontFamily = PeachType.Mono,313        fontSize = 11.sp,314        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),315    )316}317318@Composable319private fun Field(320    label: String,321    value: String,322    onChange: (String) -> Unit,323    singleLine: Boolean = true,324    transformation: VisualTransformation = VisualTransformation.None,325    trailing: (@Composable () -> Unit)? = null,326) {327    PeachField(328        label = label,329        value = value,330        onChange = onChange,331        singleLine = singleLine,332        mono = true,333        visualTransformation = transformation,334        trailing = trailing,335    )336}337338@Composable339private fun PasswordField(340    value: String,341    show: Boolean,342    generatorOpen: Boolean,343    onChange: (String) -> Unit,344    onToggleShow: () -> Unit,345    onToggleGenerator: () -> Unit,346) {347    Field(348        label = "password",349        value = value,350        onChange = onChange,351        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),352        trailing = {353            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,354                fontFamily = PeachType.Mono, fontSize = 9.sp,355                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))356            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,357                fontFamily = PeachType.Mono, fontSize = 9.sp,358                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))359        },360    )361}362363internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"364365@Composable366private fun GeneratorDrawer(367    open: Boolean,368    length: Int,369    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,370    password: String,371    copied: Boolean,372    onLength: (Int) -> Unit,373    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,374    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,375    onGenerate: () -> Unit,376    onCopy: () -> Unit,377    onUse: () -> Unit,378) {379    val height by animateDpAsState(380        targetValue = if (open) 326.dp else 0.dp,381        animationSpec = tween(320, easing = PeachMotion.Spring),382        label = "gen-drawer",383    )384    Box(385        Modifier.fillMaxWidth().height(height).clipToBounds()386            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),387    ) {388        GeneratorBody(389            length = length,390            upper = upper, lower = lower, numbers = numbers, symbols = symbols,391            password = password, copied = copied,392            onLength = onLength,393            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,394            onGenerate = onGenerate, onCopy = onCopy,395            showOutput = true,396            onUse = onUse,397        )398    }399    if (open) Spacer(Modifier.height(14.dp))400}401402/**403 * Shared generator body — used by the inline drawer here and the detail404 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.405 */406@Composable407internal fun GeneratorBody(408    length: Int,409    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,410    password: String,411    copied: Boolean,412    onLength: (Int) -> Unit,413    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,414    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,415    onGenerate: () -> Unit,416    onCopy: () -> Unit,417    showOutput: Boolean = false,418    onUse: (() -> Unit)? = null,419) {420    val accent = LocalPeachAccent.current421    Column(Modifier.fillMaxWidth().padding(16.dp)) {422        if (showOutput) {423            Box(424                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))425                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))426                    .background(accent.copy(alpha = 0.08f))427                    .padding(horizontal = 12.dp, vertical = 10.dp),428            ) {429                Text(430                    text = password.ifEmpty { "tap generate" },431                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,432                    fontFamily = PeachType.Mono,433                    fontSize = 11.sp,434                    maxLines = 2,435                    overflow = TextOverflow.Ellipsis,436                )437            }438            Spacer(Modifier.height(10.dp))439        }440        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {441            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)442            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)443        }444        Slider(445            value = length.toFloat(),446            onValueChange = { onLength(it.toInt()) },447            valueRange = 8f..128f,448            colors = SliderDefaults.colors(449                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,450            ),451        )452        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {453            TogglePill("A-Z", upper, onUpper)454            TogglePill("a-z", lower, onLower)455            TogglePill("0-9", numbers, onNumbers)456            TogglePill("!@#", symbols, onSymbols)457        }458        Spacer(Modifier.height(12.dp))459        Row(verticalAlignment = Alignment.CenterVertically) {460            Box(461                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))462                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))463                    .background(accent.copy(alpha = 0.10f))464                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),465                contentAlignment = Alignment.Center,466            ) {467                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)468            }469            Spacer(Modifier.width(8.dp))470            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })471        }472        onUse?.let {473            Spacer(Modifier.height(10.dp))474            Box(475                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))476                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))477                    .background(accent.copy(alpha = 0.14f))478                    .clickable(enabled = password.isNotEmpty(), onClick = it)479                    .padding(vertical = 12.dp),480                contentAlignment = Alignment.Center,481            ) {482                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)483            }484        }485    }486}487488@Composable489private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {490    val accent = LocalPeachAccent.current491    Box(492        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))493            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))494            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)495            .clickable { onToggle(!on) }496            .padding(horizontal = 10.dp, vertical = 6.dp),497    ) {498        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)499    }500}501502// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted503// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.