EntryEditScreen.kt

22.6 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("url", f.url, vm::setUrl, joined = true, divider = true)122                            AdditionalUrlsEditor(123                                urls = f.additionalUrls,124                                onChange = vm::setAdditionalUrl,125                                onAdd = vm::addAdditionalUrl,126                                onRemove = vm::removeAdditionalUrl,127                                embedded = true,128                            )129                            IdentitySuggestField(130                                "username", f.username, usernameSuggestions, vm::setUsername,131                                joined = true,132                                divider = true,133                            )134                            IdentitySuggestField(135                                "email", f.email, emailSuggestions, vm::setEmail,136                                joined = true,137                                divider = true,138                            )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                                joined = true,147                                divider = true,148                            )149                            GeneratorDrawer(150                                open = f.generatorOpen,151                                length = f.genLength,152                                upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,153                                password = f.password,154                                copied = f.genCopied,155                                onLength = vm::setGenLength,156                                onUpper = vm::setGenUpper, onLower = vm::setGenLower,157                                onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,158                                onGenerate = vm::generate,159                                onCopy = vm::copyGenerated,160                                onUse = vm::useGeneratedPassword,161                                embedded = true,162                            )163                            PasswordHistorySection(164                                history = f.history,165                                expanded = f.showHistory,166                                onToggle = vm::toggleHistory,167                                onRestore = vm::restorePassword,168                                onCopy = vm::copyHistoryPassword,169                                embedded = true,170                            )171                        }172                        OptionalLoginAddLinks(173                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),174                            showNotesLink = shouldShowLoginNotesLink(175                                isNew = vm.isNew,176                                type = f.type,177                                revealed = f.showNotes,178                                notes = f.notes,179                            ),180                            onAddTotp = vm::revealTotp,181                            onAddNotes = vm::revealNotes,182                        )183                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {184                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)185                        }186                    }187                    if (f.type == "card") {188                        CardEditFields(189                            cardNickname = f.cardNickname,190                            cardNumber = f.cardNumber,191                            cardholderName = f.cardholderName,192                            expiry = f.expiry,193                            cvc = f.cvc,194                            showCvc = f.showCvc,195                            network = f.cardNetwork,196                            onCardNickname = vm::setCardNickname,197                            onCardNumber = vm::setCardNumber,198                            onCardholder = vm::setCardholderName,199                            onExpiry = vm::setExpiry,200                            onCvc = vm::setCvc,201                            onToggleShowCvc = vm::toggleShowCvc,202                        )203                    }204                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {205                        Field("notes", f.notes, vm::setNotes, singleLine = false)206                    }207                    if (f.type == "login") {208                        CanaryEditSection(209                            entryId = id ?: "new",210                            modifiedAt = f.canary?.lastPinged ?: 0L,211                            canary = f.canary,212                            isPro = vm.isPro,213                            message = f.canaryMessage,214                            testing = f.canaryTesting,215                            onSave = vm::setCanary,216                            onTest = vm::testCanary,217                        )218                    }219                    TagChipEditor(220                        tags = f.tags,221                        allTags = vm.allTags,222                        onAdd = vm::addTag,223                        onRemove = vm::removeTag,224                    )225226                    f.error?.let {227                        Spacer(Modifier.height(PeachSpacing.sm))228                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)229                    }230                    Spacer(Modifier.height(PeachSpacing.xl))231                }232            }233            EntryEditBottomActions(234                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),235                canSave = canSaveEditForm(f) && !f.saving,236                onCancel = onBack,237                onSave = { vm.save(onBack) },238            )239        }240    }241}242243@Composable244private fun EntryEditBottomActions(245    saveLabel: String,246    canSave: Boolean,247    onCancel: () -> Unit,248    onSave: () -> Unit,249) {250    val accent = LocalPeachAccent.current251    Row(252        Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),253        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),254    ) {255        Surface(256            modifier = Modifier.weight(1f).clickable(onClick = onCancel),257            color = peachColors.S1,258            shape = RoundedCornerShape(PeachRadius.StackCard),259            border = BorderStroke(1.dp, peachColors.T3),260        ) {261            Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)262        }263        Surface(264            modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),265            color = if (canSave) accent else peachColors.S2,266            shape = RoundedCornerShape(PeachRadius.StackCard),267        ) {268            Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)269        }270    }271}272273/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */274internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {275    !isNew -> "edit"276    type == "note" -> "new note"277    type == "card" -> "new card"278    else -> "new entry"279}280281internal fun entryEditTypeSegments(): List<PeachSegment> = listOf(282    PeachSegment("login", "login"),283    PeachSegment("card", "card"),284)285286internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {287    saving -> "saving..."288    isNew -> "create"289    else -> "save"290}291292internal fun canSaveEditForm(form: EditForm): Boolean {293    if (form.title.isBlank()) return false294    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false295    return true296}297298/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */299internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f300301internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =302    type == "login" && (revealed || totp.isNotBlank())303304internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =305    type == "login" && (revealed || notes.isNotBlank())306307internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =308    type == "login" && !shouldShowLoginTotp(type, revealed, totp)309310internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =311    type == "login" && !shouldShowLoginNotes(type, revealed, notes)312313internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =314    !isNew && shouldShowAddNotesLink(type, revealed, notes)315316@Composable317private fun OptionalLoginAddLinks(318    showTotpLink: Boolean,319    showNotesLink: Boolean,320    onAddTotp: () -> Unit,321    onAddNotes: () -> Unit,322) {323    if (!showTotpLink && !showNotesLink) return324    Row(325        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),326        horizontalArrangement = Arrangement.spacedBy(14.dp),327    ) {328        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)329        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)330    }331}332333@Composable334private fun AddFieldLink(label: String, onClick: () -> Unit) {335    Text(336        label,337        color = LocalPeachAccent.current,338        fontFamily = PeachType.Mono,339        fontSize = 11.sp,340        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),341    )342}343344@Composable345private fun Field(346    label: String,347    value: String,348    onChange: (String) -> Unit,349    singleLine: Boolean = true,350    transformation: VisualTransformation = VisualTransformation.None,351    trailing: (@Composable () -> Unit)? = null,352    joined: Boolean = false,353    divider: Boolean = false,354) {355    PeachField(356        label = label,357        value = value,358        onChange = onChange,359        singleLine = singleLine,360        mono = true,361        visualTransformation = transformation,362        trailing = trailing,363        joined = joined,364        divider = divider,365    )366}367368@Composable369private fun PasswordField(370    value: String,371    show: Boolean,372    generatorOpen: Boolean,373    onChange: (String) -> Unit,374    onToggleShow: () -> Unit,375    onToggleGenerator: () -> Unit,376    joined: Boolean = false,377    divider: Boolean = false,378) {379    Field(380        label = "password",381        value = value,382        onChange = onChange,383        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),384        joined = joined,385        divider = divider,386        trailing = {387            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {388                PasswordAction(if (show) "hide" else "show", onToggleShow)389                PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)390            }391        },392    )393}394395@Composable396private fun PasswordAction(label: String, onClick: () -> Unit) {397    val accent = LocalPeachAccent.current398    Text(399        label,400        color = accent,401        fontFamily = PeachType.Mono,402        fontSize = 9.sp,403        modifier = Modifier404            .clip(RoundedCornerShape(6.dp))405            .background(accent.copy(alpha = 0.08f))406            .clickable(onClick = onClick)407            .padding(horizontal = 7.dp, vertical = 5.dp),408    )409}410411internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"412413@Composable414private fun GeneratorDrawer(415    open: Boolean,416    length: Int,417    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,418    password: String,419    copied: Boolean,420    onLength: (Int) -> Unit,421    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,422    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,423    onGenerate: () -> Unit,424    onCopy: () -> Unit,425    onUse: () -> Unit,426    embedded: Boolean = false,427) {428    val height by animateDpAsState(429        targetValue = if (open) 326.dp else 0.dp,430        animationSpec = tween(320, easing = PeachMotion.Spring),431        label = "gen-drawer",432    )433    Box(434        Modifier.fillMaxWidth().height(height).clipToBounds()435            .clip(RoundedCornerShape(12.dp))436            .background(peachColors.S2)437            .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),438    ) {439        GeneratorBody(440            length = length,441            upper = upper, lower = lower, numbers = numbers, symbols = symbols,442            password = password, copied = copied,443            onLength = onLength,444            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,445            onGenerate = onGenerate, onCopy = onCopy,446            showOutput = true,447            onUse = onUse,448        )449    }450    if (open) Spacer(Modifier.height(if (embedded) 8.dp else 14.dp))451}452453/**454 * Shared generator body — used by the inline drawer here and the detail455 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.456 */457@Composable458internal fun GeneratorBody(459    length: Int,460    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,461    password: String,462    copied: Boolean,463    onLength: (Int) -> Unit,464    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,465    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,466    onGenerate: () -> Unit,467    onCopy: () -> Unit,468    showOutput: Boolean = false,469    onUse: (() -> Unit)? = null,470) {471    val accent = LocalPeachAccent.current472    Column(Modifier.fillMaxWidth().padding(16.dp)) {473        if (showOutput) {474            Box(475                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))476                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))477                    .background(accent.copy(alpha = 0.08f))478                    .padding(horizontal = 12.dp, vertical = 10.dp),479            ) {480                Text(481                    text = password.ifEmpty { "tap generate" },482                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,483                    fontFamily = PeachType.Mono,484                    fontSize = 11.sp,485                    maxLines = 2,486                    overflow = TextOverflow.Ellipsis,487                )488            }489            Spacer(Modifier.height(10.dp))490        }491        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {492            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)493            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)494        }495        Slider(496            value = length.toFloat(),497            onValueChange = { onLength(it.toInt()) },498            valueRange = 8f..128f,499            colors = SliderDefaults.colors(500                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,501            ),502        )503        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {504            TogglePill("A-Z", upper, onUpper)505            TogglePill("a-z", lower, onLower)506            TogglePill("0-9", numbers, onNumbers)507            TogglePill("!@#", symbols, onSymbols)508        }509        Spacer(Modifier.height(12.dp))510        Row(verticalAlignment = Alignment.CenterVertically) {511            Box(512                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))513                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))514                    .background(accent.copy(alpha = 0.10f))515                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),516                contentAlignment = Alignment.Center,517            ) {518                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)519            }520            Spacer(Modifier.width(8.dp))521            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })522        }523        onUse?.let {524            Spacer(Modifier.height(10.dp))525            Box(526                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))527                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))528                    .background(accent.copy(alpha = 0.14f))529                    .clickable(enabled = password.isNotEmpty(), onClick = it)530                    .padding(vertical = 12.dp),531                contentAlignment = Alignment.Center,532            ) {533                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)534            }535        }536    }537}538539@Composable540private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {541    val accent = LocalPeachAccent.current542    Box(543        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))544            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))545            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)546            .clickable { onToggle(!on) }547            .padding(horizontal = 10.dp, vertical = 6.dp),548    ) {549        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)550    }551}552553// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted554// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.