EntryEditScreen.kt

22.3 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(vm.isNew),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                            IdentitySuggestField(123                                "username", f.username, usernameSuggestions, vm::setUsername,124                                joined = true,125                                divider = true,126                            )127                            IdentitySuggestField(128                                "email", f.email, emailSuggestions, vm::setEmail,129                                joined = true,130                                divider = true,131                            )132                            PasswordField(133                                value = f.password,134                                show = f.showPassword,135                                generatorOpen = f.generatorOpen,136                                onChange = vm::setPassword,137                                onToggleShow = vm::toggleShowPassword,138                                onToggleGenerator = vm::toggleGenerator,139                                joined = true,140                            )141                        }142                        GeneratorDrawer(143                            open = f.generatorOpen,144                            length = f.genLength,145                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,146                            password = f.password,147                            copied = f.genCopied,148                            onLength = vm::setGenLength,149                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,150                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,151                            onGenerate = vm::generate,152                            onCopy = vm::copyGenerated,153                            onUse = vm::useGeneratedPassword,154                        )155                        AdditionalUrlsEditor(156                            urls = f.additionalUrls,157                            onChange = vm::setAdditionalUrl,158                            onAdd = vm::addAdditionalUrl,159                            onRemove = vm::removeAdditionalUrl,160                        )161                        PasswordHistorySection(162                            history = f.history,163                            expanded = f.showHistory,164                            onToggle = vm::toggleHistory,165                            onRestore = vm::restorePassword,166                            onCopy = vm::copyHistoryPassword,167                        )168                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {169                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)170                        }171                    }172                    if (f.type == "card") {173                        CardEditFields(174                            cardNickname = f.cardNickname,175                            cardNumber = f.cardNumber,176                            cardholderName = f.cardholderName,177                            expiry = f.expiry,178                            cvc = f.cvc,179                            showCvc = f.showCvc,180                            network = f.cardNetwork,181                            onCardNickname = vm::setCardNickname,182                            onCardNumber = vm::setCardNumber,183                            onCardholder = vm::setCardholderName,184                            onExpiry = vm::setExpiry,185                            onCvc = vm::setCvc,186                            onToggleShowCvc = vm::toggleShowCvc,187                        )188                    }189                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {190                        Field("notes", f.notes, vm::setNotes, singleLine = false)191                    }192                    if (f.type == "login") {193                        OptionalLoginAddLinks(194                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),195                            showNotesLink = shouldShowLoginNotesLink(196                                isNew = vm.isNew,197                                type = f.type,198                                revealed = f.showNotes,199                                notes = f.notes,200                            ),201                            onAddTotp = vm::revealTotp,202                            onAddNotes = vm::revealNotes,203                        )204                        CanaryEditSection(205                            entryId = id ?: "new",206                            modifiedAt = f.canary?.lastPinged ?: 0L,207                            canary = f.canary,208                            isPro = vm.isPro,209                            message = f.canaryMessage,210                            testing = f.canaryTesting,211                            onSave = vm::setCanary,212                            onTest = vm::testCanary,213                        )214                    }215                    TagChipEditor(216                        tags = f.tags,217                        allTags = vm.allTags,218                        onAdd = vm::addTag,219                        onRemove = vm::removeTag,220                    )221222                    f.error?.let {223                        Spacer(Modifier.height(PeachSpacing.sm))224                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)225                    }226                    Spacer(Modifier.height(PeachSpacing.xl))227                }228            }229            EntryEditBottomActions(230                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),231                canSave = canSaveEditForm(f) && !f.saving,232                onCancel = onBack,233                onSave = { vm.save(onBack) },234            )235        }236    }237}238239@Composable240private fun EntryEditBottomActions(241    saveLabel: String,242    canSave: Boolean,243    onCancel: () -> Unit,244    onSave: () -> Unit,245) {246    val accent = LocalPeachAccent.current247    Row(248        Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),249        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),250    ) {251        Surface(252            modifier = Modifier.weight(1f).clickable(onClick = onCancel),253            color = peachColors.S1,254            shape = RoundedCornerShape(PeachRadius.StackCard),255            border = BorderStroke(1.dp, peachColors.T3),256        ) {257            Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)258        }259        Surface(260            modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),261            color = if (canSave) accent else peachColors.S2,262            shape = RoundedCornerShape(PeachRadius.StackCard),263        ) {264            Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)265        }266    }267}268269/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */270internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {271    !isNew -> "edit"272    type == "note" -> "new note"273    type == "card" -> "new card"274    else -> "new entry"275}276277internal fun entryEditTypeSegments(isNew: Boolean): List<PeachSegment> = buildList {278    add(PeachSegment("login", "login"))279    if (!isNew) add(PeachSegment("note", "note"))280    add(PeachSegment("card", "card"))281}282283internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {284    saving -> "saving..."285    isNew -> "create"286    else -> "save"287}288289internal fun canSaveEditForm(form: EditForm): Boolean {290    if (form.title.isBlank()) return false291    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false292    return true293}294295/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */296internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f297298internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =299    type == "login" && (revealed || totp.isNotBlank())300301internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =302    type == "login" && (revealed || notes.isNotBlank())303304internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =305    type == "login" && !shouldShowLoginTotp(type, revealed, totp)306307internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =308    type == "login" && !shouldShowLoginNotes(type, revealed, notes)309310internal fun shouldShowLoginNotesLink(isNew: Boolean, type: String, revealed: Boolean, notes: String): Boolean =311    !isNew && shouldShowAddNotesLink(type, revealed, notes)312313@Composable314private fun OptionalLoginAddLinks(315    showTotpLink: Boolean,316    showNotesLink: Boolean,317    onAddTotp: () -> Unit,318    onAddNotes: () -> Unit,319) {320    if (!showTotpLink && !showNotesLink) return321    Row(322        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),323        horizontalArrangement = Arrangement.spacedBy(14.dp),324    ) {325        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)326        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)327    }328}329330@Composable331private fun AddFieldLink(label: String, onClick: () -> Unit) {332    Text(333        label,334        color = LocalPeachAccent.current,335        fontFamily = PeachType.Mono,336        fontSize = 11.sp,337        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),338    )339}340341@Composable342private fun Field(343    label: String,344    value: String,345    onChange: (String) -> Unit,346    singleLine: Boolean = true,347    transformation: VisualTransformation = VisualTransformation.None,348    trailing: (@Composable () -> Unit)? = null,349    joined: Boolean = false,350    divider: Boolean = false,351) {352    PeachField(353        label = label,354        value = value,355        onChange = onChange,356        singleLine = singleLine,357        mono = true,358        visualTransformation = transformation,359        trailing = trailing,360        joined = joined,361        divider = divider,362    )363}364365@Composable366private fun PasswordField(367    value: String,368    show: Boolean,369    generatorOpen: Boolean,370    onChange: (String) -> Unit,371    onToggleShow: () -> Unit,372    onToggleGenerator: () -> Unit,373    joined: Boolean = false,374) {375    Field(376        label = "password",377        value = value,378        onChange = onChange,379        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),380        joined = joined,381        trailing = {382            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {383                PasswordAction(if (show) "hide" else "show", onToggleShow)384                PasswordAction(generatorToggleLabel(generatorOpen), onToggleGenerator)385            }386        },387    )388}389390@Composable391private fun PasswordAction(label: String, onClick: () -> Unit) {392    val accent = LocalPeachAccent.current393    Text(394        label,395        color = accent,396        fontFamily = PeachType.Mono,397        fontSize = 9.sp,398        modifier = Modifier399            .clip(RoundedCornerShape(6.dp))400            .background(accent.copy(alpha = 0.08f))401            .clickable(onClick = onClick)402            .padding(horizontal = 7.dp, vertical = 5.dp),403    )404}405406internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"407408@Composable409private fun GeneratorDrawer(410    open: Boolean,411    length: Int,412    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,413    password: String,414    copied: Boolean,415    onLength: (Int) -> Unit,416    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,417    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,418    onGenerate: () -> Unit,419    onCopy: () -> Unit,420    onUse: () -> Unit,421) {422    val height by animateDpAsState(423        targetValue = if (open) 326.dp else 0.dp,424        animationSpec = tween(320, easing = PeachMotion.Spring),425        label = "gen-drawer",426    )427    Box(428        Modifier.fillMaxWidth().height(height).clipToBounds()429            .clip(RoundedCornerShape(12.dp))430            .background(peachColors.S2)431            .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)),432    ) {433        GeneratorBody(434            length = length,435            upper = upper, lower = lower, numbers = numbers, symbols = symbols,436            password = password, copied = copied,437            onLength = onLength,438            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,439            onGenerate = onGenerate, onCopy = onCopy,440            showOutput = true,441            onUse = onUse,442        )443    }444    if (open) Spacer(Modifier.height(14.dp))445}446447/**448 * Shared generator body — used by the inline drawer here and the detail449 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.450 */451@Composable452internal fun GeneratorBody(453    length: Int,454    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,455    password: String,456    copied: Boolean,457    onLength: (Int) -> Unit,458    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,459    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,460    onGenerate: () -> Unit,461    onCopy: () -> Unit,462    showOutput: Boolean = false,463    onUse: (() -> Unit)? = null,464) {465    val accent = LocalPeachAccent.current466    Column(Modifier.fillMaxWidth().padding(16.dp)) {467        if (showOutput) {468            Box(469                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))470                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))471                    .background(accent.copy(alpha = 0.08f))472                    .padding(horizontal = 12.dp, vertical = 10.dp),473            ) {474                Text(475                    text = password.ifEmpty { "tap generate" },476                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,477                    fontFamily = PeachType.Mono,478                    fontSize = 11.sp,479                    maxLines = 2,480                    overflow = TextOverflow.Ellipsis,481                )482            }483            Spacer(Modifier.height(10.dp))484        }485        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {486            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)487            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)488        }489        Slider(490            value = length.toFloat(),491            onValueChange = { onLength(it.toInt()) },492            valueRange = 8f..128f,493            colors = SliderDefaults.colors(494                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,495            ),496        )497        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {498            TogglePill("A-Z", upper, onUpper)499            TogglePill("a-z", lower, onLower)500            TogglePill("0-9", numbers, onNumbers)501            TogglePill("!@#", symbols, onSymbols)502        }503        Spacer(Modifier.height(12.dp))504        Row(verticalAlignment = Alignment.CenterVertically) {505            Box(506                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))507                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))508                    .background(accent.copy(alpha = 0.10f))509                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),510                contentAlignment = Alignment.Center,511            ) {512                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)513            }514            Spacer(Modifier.width(8.dp))515            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })516        }517        onUse?.let {518            Spacer(Modifier.height(10.dp))519            Box(520                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))521                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))522                    .background(accent.copy(alpha = 0.14f))523                    .clickable(enabled = password.isNotEmpty(), onClick = it)524                    .padding(vertical = 12.dp),525                contentAlignment = Alignment.Center,526            ) {527                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)528            }529        }530    }531}532533@Composable534private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {535    val accent = LocalPeachAccent.current536    Box(537        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))538            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))539            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)540            .clickable { onToggle(!on) }541            .padding(horizontal = 10.dp, vertical = 6.dp),542    ) {543        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)544    }545}546547// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted548// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.