EntryEditScreen.kt

25.2 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    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), onHome = onHome)9192            Spacer(Modifier.height(PeachSpacing.lg))9394            Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) {95                // Ghost Garamond 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 = PeachType.ghostMark.copy(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 = entryEditTypeSegments(),113                        selectedKey = f.type,114                        onSelected = vm::setType,115                    )116                    Spacer(Modifier.height(PeachSpacing.lg))117118                    Field("title", f.title, vm::setTitle)119                    if (f.type == "login") {120                        PeachFieldStack {121                            Field(122                                "url", f.url, vm::setUrl,123                                joined = true,124                                divider = true,125                                trailing = if (f.additionalUrls.isEmpty()) {126                                    { AddFieldLink("+ add", vm::addAdditionalUrl) }127                                } else {128                                    null129                                },130                            )131                            if (f.additionalUrls.isNotEmpty()) {132                                AdditionalUrlsEditor(133                                    urls = f.additionalUrls,134                                    onChange = vm::setAdditionalUrl,135                                    onAdd = vm::addAdditionalUrl,136                                    onRemove = vm::removeAdditionalUrl,137                                )138                            }139                            IdentitySuggestField(140                                "username", f.username, usernameSuggestions, vm::setUsername,141                                joined = true,142                                divider = true,143                            )144                            IdentitySuggestField(145                                "email", f.email, emailSuggestions, vm::setEmail,146                                joined = true,147                                divider = true,148                            )149                            PasswordField(150                                value = f.password,151                                show = f.showPassword,152                                generatorOpen = f.generatorOpen,153                                onChange = vm::setPassword,154                                onToggleShow = vm::toggleShowPassword,155                                onToggleGenerator = vm::toggleGenerator,156                                joined = true,157                            )158                            PasswordHistorySection(159                                history = f.history,160                                expanded = f.showHistory,161                                onToggle = vm::toggleHistory,162                                onRestore = vm::restorePassword,163                                onCopy = vm::copyHistoryPassword,164                            )165                        }166                        GeneratorDrawer(167                            open = f.generatorOpen,168                            mode = f.genMode,169                            length = f.genLength,170                            wordCount = f.genWordCount,171                            separator = f.genSeparator,172                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,173                            password = f.password,174                            copied = f.genCopied,175                            onMode = vm::setGenMode,176                            onLength = vm::setGenLength,177                            onWordCount = vm::setGenWordCount,178                            onSeparator = vm::setGenSeparator,179                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,180                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,181                            onGenerate = vm::generate,182                            onCopy = vm::copyGenerated,183                            onUse = vm::useGeneratedPassword,184                        )185                        OptionalLoginAddLinks(186                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),187                            showNotesLink = shouldShowLoginNotesLink(188                                isNew = vm.isNew,189                                type = f.type,190                                revealed = f.showNotes,191                                notes = f.notes,192                            ),193                            onAddTotp = vm::revealTotp,194                            onAddNotes = vm::revealNotes,195                        )196                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {197                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)198                        }199                    }200                    if (f.type == "card") {201                        CardEditFields(202                            cardNickname = f.cardNickname,203                            cardNumber = f.cardNumber,204                            cardholderName = f.cardholderName,205                            expiry = f.expiry,206                            cvc = f.cvc,207                            showCvc = f.showCvc,208                            network = f.cardNetwork,209                            onCardNickname = vm::setCardNickname,210                            onCardNumber = vm::setCardNumber,211                            onCardholder = vm::setCardholderName,212                            onExpiry = vm::setExpiry,213                            onCvc = vm::setCvc,214                            onToggleShowCvc = vm::toggleShowCvc,215                        )216                    }217                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {218                        Field("notes", f.notes, vm::setNotes, singleLine = false)219                    }220                    if (f.type == "login") {221                        CanaryEditSection(222                            entryId = id ?: "new",223                            modifiedAt = f.canary?.lastPinged ?: 0L,224                            canary = f.canary,225                            isPro = vm.isPro,226                            message = f.canaryMessage,227                            testing = f.canaryTesting,228                            onSave = vm::setCanary,229                            onTest = vm::testCanary,230                        )231                    }232                    TagChipEditor(233                        tags = f.tags,234                        allTags = vm.allTags,235                        onAdd = vm::addTag,236                        onRemove = vm::removeTag,237                    )238239                    f.error?.let {240                        Spacer(Modifier.height(PeachSpacing.sm))241                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)242                    }243                    Spacer(Modifier.height(PeachSpacing.xl))244                }245            }246            EntryEditBottomActions(247                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),248                canSave = canSaveEditForm(f) && !f.saving,249                onCancel = onBack,250                onSave = { vm.save(onBack) },251            )252        }253    }254}255256@Composable257private fun EntryEditBottomActions(258    saveLabel: String,259    canSave: Boolean,260    onCancel: () -> Unit,261    onSave: () -> Unit,262) {263    val accent = LocalPeachAccent.current264    Row(265        Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),266        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),267    ) {268        Surface(269            modifier = Modifier.weight(1f).clickable(onClick = onCancel),270            color = peachColors.S1,271            shape = RoundedCornerShape(PeachRadius.StackCard),272            border = BorderStroke(1.dp, peachColors.T3),273        ) {274            Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)275        }276        Surface(277            modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),278            color = if (canSave) accent else peachColors.S2,279            shape = RoundedCornerShape(PeachRadius.StackCard),280        ) {281            Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)282        }283    }284}285286/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */287internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {288    !isNew -> "edit"289    type == "note" -> "new note"290    type == "card" -> "new card"291    else -> "new entry"292}293294internal fun entryEditTypeSegments(): List<PeachSegment> = listOf(295    PeachSegment("login", "login"),296    PeachSegment("card", "card"),297)298299internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {300    saving -> "saving..."301    isNew -> "create"302    else -> "save"303}304305internal fun canSaveEditForm(form: EditForm): Boolean {306    if (form.title.isBlank()) return false307    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false308    return true309}310311/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */312internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f313314internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =315    type == "login" && (revealed || totp.isNotBlank())316317internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =318    type == "login" && (revealed || notes.isNotBlank())319320internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =321    type == "login" && !shouldShowLoginTotp(type, revealed, totp)322323internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =324    type == "login" && !shouldShowLoginNotes(type, revealed, notes)325326internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =327    !isNew && shouldShowAddNotesLink(type, revealed, notes)328329@Composable330private fun OptionalLoginAddLinks(331    showTotpLink: Boolean,332    showNotesLink: Boolean,333    onAddTotp: () -> Unit,334    onAddNotes: () -> Unit,335) {336    if (!showTotpLink && !showNotesLink) return337    PeachFieldStack {338        Text(339            "OPTIONAL FIELDS",340            color = peachColors.T2,341            fontFamily = PeachType.Mono,342            fontSize = 8.sp,343            letterSpacing = 0.08.em,344            modifier = Modifier.padding(start = PeachSpacing.lg, top = PeachSpacing.md),345        )346        Row(347            Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),348            horizontalArrangement = Arrangement.spacedBy(14.dp),349        ) {350            if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)351            if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)352        }353    }354}355356@Composable357private fun AddFieldLink(label: String, onClick: () -> Unit) {358    Text(359        label,360        color = LocalPeachAccent.current,361        fontFamily = PeachType.Mono,362        fontSize = 11.sp,363        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),364    )365}366367@Composable368private fun Field(369    label: String,370    value: String,371    onChange: (String) -> Unit,372    singleLine: Boolean = true,373    transformation: VisualTransformation = VisualTransformation.None,374    trailing: (@Composable () -> Unit)? = null,375    joined: Boolean = false,376    divider: Boolean = false,377) {378    PeachField(379        label = label,380        value = value,381        onChange = onChange,382        singleLine = singleLine,383        mono = true,384        visualTransformation = transformation,385        trailing = trailing,386        joined = joined,387        divider = divider,388    )389}390391@Composable392private fun PasswordField(393    value: String,394    show: Boolean,395    generatorOpen: Boolean,396    onChange: (String) -> Unit,397    onToggleShow: () -> Unit,398    onToggleGenerator: () -> Unit,399    joined: Boolean = false,400    divider: Boolean = false,401) {402    Field(403        label = "password",404        value = value,405        onChange = onChange,406        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),407        joined = joined,408        divider = divider,409        trailing = {410            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {411                PasswordAction(if (show) "hide" else "show", onToggleShow)412                PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)413            }414        },415    )416}417418@Composable419private fun PasswordAction(label: String, onClick: () -> Unit) {420    val accent = LocalPeachAccent.current421    Text(422        label,423        color = accent,424        fontFamily = PeachType.Mono,425        fontSize = 9.sp,426        modifier = Modifier427            .clip(RoundedCornerShape(6.dp))428            .background(accent.copy(alpha = 0.08f))429            .clickable(onClick = onClick)430            .padding(horizontal = 7.dp, vertical = 5.dp),431    )432}433434internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"435436@Composable437private fun GeneratorDrawer(438    open: Boolean,439    mode: GeneratorMode,440    length: Int,441    wordCount: Int,442    separator: String,443    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,444    password: String,445    copied: Boolean,446    onMode: (GeneratorMode) -> Unit,447    onLength: (Int) -> Unit,448    onWordCount: (Int) -> Unit,449    onSeparator: (String) -> Unit,450    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,451    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,452    onGenerate: () -> Unit,453    onCopy: () -> Unit,454    onUse: () -> Unit,455) {456    val height by animateDpAsState(457        targetValue = if (open) 370.dp else 0.dp,458        animationSpec = tween(320, easing = PeachMotion.Spring),459        label = "gen-drawer",460    )461    Box(462        Modifier.fillMaxWidth().height(height).clipToBounds()463            .clip(RoundedCornerShape(12.dp))464            .background(peachColors.S2)465            .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),466    ) {467        GeneratorBody(468            mode = mode,469            length = length,470            wordCount = wordCount,471            separator = separator,472            upper = upper, lower = lower, numbers = numbers, symbols = symbols,473            password = password, copied = copied,474            onMode = onMode,475            onLength = onLength,476            onWordCount = onWordCount,477            onSeparator = onSeparator,478            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,479            onGenerate = onGenerate, onCopy = onCopy,480            showOutput = true,481            onUse = onUse,482        )483    }484    if (open) Spacer(Modifier.height(14.dp))485}486487/**488 * Shared generator body — used by the inline drawer here and the detail489 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.490 */491@Composable492internal fun GeneratorBody(493    mode: GeneratorMode,494    length: Int,495    wordCount: Int,496    separator: String,497    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,498    password: String,499    copied: Boolean,500    onMode: (GeneratorMode) -> Unit,501    onLength: (Int) -> Unit,502    onWordCount: (Int) -> Unit,503    onSeparator: (String) -> Unit,504    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,505    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,506    onGenerate: () -> Unit,507    onCopy: () -> Unit,508    showOutput: Boolean = false,509    onUse: (() -> Unit)? = null,510) {511    val accent = LocalPeachAccent.current512    Column(Modifier.fillMaxWidth().padding(16.dp)) {513        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {514            ChoicePill("password", mode == GeneratorMode.Password) { onMode(GeneratorMode.Password) }515            ChoicePill("passphrase", mode == GeneratorMode.Passphrase) { onMode(GeneratorMode.Passphrase) }516        }517        Spacer(Modifier.height(10.dp))518        if (showOutput) {519            Box(520                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))521                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))522                    .background(accent.copy(alpha = 0.08f))523                    .padding(horizontal = 12.dp, vertical = 10.dp),524            ) {525                Text(526                    text = password.ifEmpty { "tap generate" },527                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,528                    fontFamily = PeachType.Mono,529                    fontSize = 11.sp,530                    maxLines = 2,531                    overflow = TextOverflow.Ellipsis,532                )533            }534            Spacer(Modifier.height(10.dp))535        }536        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {537            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)538            Text(539                if (mode == GeneratorMode.Passphrase) "word count $wordCount" else "length $length",540                color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp,541            )542        }543        Slider(544            value = if (mode == GeneratorMode.Passphrase) wordCount.toFloat() else length.toFloat(),545            onValueChange = {546                if (mode == GeneratorMode.Passphrase) onWordCount(it.toInt()) else onLength(it.toInt())547            },548            valueRange = if (mode == GeneratorMode.Passphrase) 4f..12f else 8f..256f,549            colors = SliderDefaults.colors(550                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,551            ),552        )553        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {554            if (mode == GeneratorMode.Passphrase) {555                listOf("-", " ", ".", "_").forEach { value ->556                    ChoicePill(if (value == " ") "space" else value, separator == value) { onSeparator(value) }557                }558            } else {559                TogglePill("A-Z", upper, onUpper)560                TogglePill("a-z", lower, onLower)561                TogglePill("0-9", numbers, onNumbers)562                TogglePill("!@#", symbols, onSymbols)563            }564        }565        Spacer(Modifier.height(12.dp))566        Row(verticalAlignment = Alignment.CenterVertically) {567            Box(568                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))569                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))570                    .background(accent.copy(alpha = 0.10f))571                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),572                contentAlignment = Alignment.Center,573            ) {574                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)575            }576            Spacer(Modifier.width(8.dp))577            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })578        }579        onUse?.let {580            Spacer(Modifier.height(10.dp))581            Box(582                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))583                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))584                    .background(accent.copy(alpha = 0.14f))585                    .clickable(enabled = password.isNotEmpty(), onClick = it)586                    .padding(vertical = 12.dp),587                contentAlignment = Alignment.Center,588            ) {589                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)590            }591        }592    }593}594595@Composable596private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {597    ChoicePill(label, on) { onToggle(!on) }598}599600@Composable601private fun ChoicePill(label: String, selected: Boolean, onClick: () -> Unit) {602    val accent = LocalPeachAccent.current603    Box(604        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))605            .border(1.dp, if (selected) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))606            .background(if (selected) accent.copy(alpha = 0.10f) else Color.Transparent)607            .clickable(onClick = onClick)608            .padding(horizontal = 10.dp, vertical = 6.dp),609    ) {610        Text(label, color = if (selected) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)611    }612}613614// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted615// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.