EntryEditScreen.kt

22.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    onOpenNote: (String) -> Unit = {},69    vm: EntryEditViewModel = hiltViewModel(),70) {71    SecureFlagEffect()7273    val f by vm.form.collectAsState()74    val usernameSuggestions by vm.usernameSuggestions.collectAsState()75    val emailSuggestions by vm.emailSuggestions.collectAsState()7677    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.78    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(79        targetValue = editGhostWatermarkTarget(f.title),80        animationSpec = tween(300),81        label = "edit-watermark",82    )8384    ShellBackground {85        Column(86            Modifier87                .fillMaxSize()88                .statusBarsPadding()89                .padding(horizontal = PeachSpacing.xl),90        ) {91            PeachVaultIdentity(editTitleBarLabel(vm.isNew, f.type), onHome = onHome)9293            Spacer(Modifier.height(PeachSpacing.lg))9495            Box(Modifier.weight(1f).fillMaxWidth().clipToBounds()) {96                // Ghost Garamond watermark behind the form (matches the detail hero):97                // the full title, fading in as the user types.98                if (watermarkAlpha > 0f) {99                    Text(100                        text = ghostWatermarkText(f.title),101                        style = PeachType.ghostMark.copy(fontSize = 140.sp),102                        color = peachColors.T0.copy(alpha = watermarkAlpha),103                        maxLines = 1,104                        softWrap = false,105                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,106                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),107                    )108                }109                Column(110                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()),111                ) {112                    PeachSegmentedControl(113                        segments = listOf(114                            PeachSegment("login", "login"),115                            PeachSegment("note", "note"),116                            PeachSegment("card", "card"),117                        ),118                        selectedKey = f.type,119                        onSelected = { t ->120                            // A brand-new note skips the form entirely and drops straight121                            // into the full-screen notes editor (brief Fix 6 item 7).122                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)123                            else vm.setType(t)124                        },125                    )126                    Spacer(Modifier.height(PeachSpacing.lg))127128                    Field("title", f.title, vm::setTitle)129                    if (f.type == "login") {130                        PeachFieldStack {131                            Field("url", f.url, vm::setUrl, joined = true, divider = true)132                            IdentitySuggestField(133                                "username", f.username, usernameSuggestions, vm::setUsername,134                                joined = true,135                                divider = true,136                            )137                            IdentitySuggestField(138                                "email", f.email, emailSuggestions, vm::setEmail,139                                joined = true,140                                divider = true,141                            )142                            PasswordField(143                                value = f.password,144                                show = f.showPassword,145                                generatorOpen = f.generatorOpen,146                                onChange = vm::setPassword,147                                onToggleShow = vm::toggleShowPassword,148                                onToggleGenerator = vm::toggleGenerator,149                                joined = true,150                            )151                        }152                        AdditionalUrlsEditor(153                            urls = f.additionalUrls,154                            onChange = vm::setAdditionalUrl,155                            onAdd = vm::addAdditionalUrl,156                            onRemove = vm::removeAdditionalUrl,157                        )158                        GeneratorDrawer(159                            open = f.generatorOpen,160                            length = f.genLength,161                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,162                            password = f.password,163                            copied = f.genCopied,164                            onLength = vm::setGenLength,165                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,166                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,167                            onGenerate = vm::generate,168                            onCopy = vm::copyGenerated,169                            onUse = vm::useGeneratedPassword,170                        )171                        PasswordHistorySection(172                            history = f.history,173                            expanded = f.showHistory,174                            onToggle = vm::toggleHistory,175                            onRestore = vm::restorePassword,176                            onCopy = vm::copyHistoryPassword,177                        )178                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {179                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)180                        }181                    }182                    if (f.type == "card") {183                        CardEditFields(184                            cardNickname = f.cardNickname,185                            cardNumber = f.cardNumber,186                            cardholderName = f.cardholderName,187                            expiry = f.expiry,188                            cvc = f.cvc,189                            showCvc = f.showCvc,190                            network = f.cardNetwork,191                            onCardNickname = vm::setCardNickname,192                            onCardNumber = vm::setCardNumber,193                            onCardholder = vm::setCardholderName,194                            onExpiry = vm::setExpiry,195                            onCvc = vm::setCvc,196                            onToggleShowCvc = vm::toggleShowCvc,197                        )198                    }199                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {200                        Field("notes", f.notes, vm::setNotes, singleLine = false)201                    }202                    if (f.type == "login") {203                        OptionalLoginAddLinks(204                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),205                            showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),206                            onAddTotp = vm::revealTotp,207                            onAddNotes = vm::revealNotes,208                        )209                        CanaryEditSection(210                            entryId = id ?: "new",211                            modifiedAt = f.canary?.lastPinged ?: 0L,212                            canary = f.canary,213                            isPro = vm.isPro,214                            message = f.canaryMessage,215                            testing = f.canaryTesting,216                            onSave = vm::setCanary,217                            onTest = vm::testCanary,218                        )219                    }220                    TagChipEditor(221                        tags = f.tags,222                        allTags = vm.allTags,223                        onAdd = vm::addTag,224                        onRemove = vm::removeTag,225                    )226227                    f.error?.let {228                        Spacer(Modifier.height(PeachSpacing.sm))229                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)230                    }231                    Spacer(Modifier.height(PeachSpacing.xl))232                }233            }234            EntryEditBottomActions(235                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),236                canSave = canSaveEditForm(f) && !f.saving,237                onCancel = onBack,238                onSave = { vm.save(onBack) },239            )240        }241    }242}243244@Composable245private fun EntryEditBottomActions(246    saveLabel: String,247    canSave: Boolean,248    onCancel: () -> Unit,249    onSave: () -> Unit,250) {251    val accent = LocalPeachAccent.current252    Row(253        Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = PeachSpacing.md),254        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.md),255    ) {256        Surface(257            modifier = Modifier.weight(1f).clickable(onClick = onCancel),258            color = peachColors.S1,259            shape = RoundedCornerShape(PeachRadius.StackCard),260            border = BorderStroke(1.dp, peachColors.T3),261        ) {262            Text("cancel", color = peachColors.T1, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)263        }264        Surface(265            modifier = Modifier.weight(1f).clickable(enabled = canSave, onClick = onSave),266            color = if (canSave) accent else peachColors.S2,267            shape = RoundedCornerShape(PeachRadius.StackCard),268        ) {269            Text(saveLabel, color = if (canSave) peachColors.Bg else peachColors.T2, style = PeachType.bodyMono, modifier = Modifier.padding(18.dp), textAlign = TextAlign.Center)270        }271    }272}273274/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */275internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {276    !isNew -> "edit"277    type == "note" -> "new note"278    type == "card" -> "new card"279    else -> "new entry"280}281282internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {283    saving -> "saving..."284    isNew -> "create"285    else -> "save"286}287288internal fun canSaveEditForm(form: EditForm): Boolean {289    if (form.title.isBlank()) return false290    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false291    return true292}293294/** A brand-new note opens the full-screen notes editor instead of the form. */295internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"296297/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */298internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f299300internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =301    type == "login" && (revealed || totp.isNotBlank())302303internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =304    type == "login" && (revealed || notes.isNotBlank())305306internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =307    type == "login" && !shouldShowLoginTotp(type, revealed, totp)308309internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =310    type == "login" && !shouldShowLoginNotes(type, revealed, notes)311312@Composable313private fun OptionalLoginAddLinks(314    showTotpLink: Boolean,315    showNotesLink: Boolean,316    onAddTotp: () -> Unit,317    onAddNotes: () -> Unit,318) {319    if (!showTotpLink && !showNotesLink) return320    Row(321        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),322        horizontalArrangement = Arrangement.spacedBy(14.dp),323    ) {324        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)325        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)326    }327}328329@Composable330private fun AddFieldLink(label: String, onClick: () -> Unit) {331    Text(332        label,333        color = LocalPeachAccent.current,334        fontFamily = PeachType.Mono,335        fontSize = 11.sp,336        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),337    )338}339340@Composable341private fun Field(342    label: String,343    value: String,344    onChange: (String) -> Unit,345    singleLine: Boolean = true,346    transformation: VisualTransformation = VisualTransformation.None,347    trailing: (@Composable () -> Unit)? = null,348    joined: Boolean = false,349    divider: Boolean = false,350) {351    PeachField(352        label = label,353        value = value,354        onChange = onChange,355        singleLine = singleLine,356        mono = true,357        visualTransformation = transformation,358        trailing = trailing,359        joined = joined,360        divider = divider,361    )362}363364@Composable365private fun PasswordField(366    value: String,367    show: Boolean,368    generatorOpen: Boolean,369    onChange: (String) -> Unit,370    onToggleShow: () -> Unit,371    onToggleGenerator: () -> Unit,372    joined: Boolean = false,373) {374    Field(375        label = "password",376        value = value,377        onChange = onChange,378        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),379        joined = joined,380        trailing = {381            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,382                fontFamily = PeachType.Mono, fontSize = 9.sp,383                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))384            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,385                fontFamily = PeachType.Mono, fontSize = 9.sp,386                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))387        },388    )389}390391internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"392393@Composable394private fun GeneratorDrawer(395    open: Boolean,396    length: Int,397    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,398    password: String,399    copied: Boolean,400    onLength: (Int) -> Unit,401    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,402    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,403    onGenerate: () -> Unit,404    onCopy: () -> Unit,405    onUse: () -> Unit,406) {407    val height by animateDpAsState(408        targetValue = if (open) 326.dp else 0.dp,409        animationSpec = tween(320, easing = PeachMotion.Spring),410        label = "gen-drawer",411    )412    Box(413        Modifier.fillMaxWidth().height(height).clipToBounds()414            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),415    ) {416        GeneratorBody(417            length = length,418            upper = upper, lower = lower, numbers = numbers, symbols = symbols,419            password = password, copied = copied,420            onLength = onLength,421            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,422            onGenerate = onGenerate, onCopy = onCopy,423            showOutput = true,424            onUse = onUse,425        )426    }427    if (open) Spacer(Modifier.height(14.dp))428}429430/**431 * Shared generator body — used by the inline drawer here and the detail432 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.433 */434@Composable435internal fun GeneratorBody(436    length: Int,437    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,438    password: String,439    copied: Boolean,440    onLength: (Int) -> Unit,441    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,442    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,443    onGenerate: () -> Unit,444    onCopy: () -> Unit,445    showOutput: Boolean = false,446    onUse: (() -> Unit)? = null,447) {448    val accent = LocalPeachAccent.current449    Column(Modifier.fillMaxWidth().padding(16.dp)) {450        if (showOutput) {451            Box(452                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))453                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))454                    .background(accent.copy(alpha = 0.08f))455                    .padding(horizontal = 12.dp, vertical = 10.dp),456            ) {457                Text(458                    text = password.ifEmpty { "tap generate" },459                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,460                    fontFamily = PeachType.Mono,461                    fontSize = 11.sp,462                    maxLines = 2,463                    overflow = TextOverflow.Ellipsis,464                )465            }466            Spacer(Modifier.height(10.dp))467        }468        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {469            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)470            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)471        }472        Slider(473            value = length.toFloat(),474            onValueChange = { onLength(it.toInt()) },475            valueRange = 8f..128f,476            colors = SliderDefaults.colors(477                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,478            ),479        )480        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {481            TogglePill("A-Z", upper, onUpper)482            TogglePill("a-z", lower, onLower)483            TogglePill("0-9", numbers, onNumbers)484            TogglePill("!@#", symbols, onSymbols)485        }486        Spacer(Modifier.height(12.dp))487        Row(verticalAlignment = Alignment.CenterVertically) {488            Box(489                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))490                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))491                    .background(accent.copy(alpha = 0.10f))492                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),493                contentAlignment = Alignment.Center,494            ) {495                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)496            }497            Spacer(Modifier.width(8.dp))498            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })499        }500        onUse?.let {501            Spacer(Modifier.height(10.dp))502            Box(503                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))504                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))505                    .background(accent.copy(alpha = 0.14f))506                    .clickable(enabled = password.isNotEmpty(), onClick = it)507                    .padding(vertical = 12.dp),508                contentAlignment = Alignment.Center,509            ) {510                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)511            }512        }513    }514}515516@Composable517private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {518    val accent = LocalPeachAccent.current519    Box(520        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))521            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))522            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)523            .clickable { onToggle(!on) }524            .padding(horizontal = 10.dp, vertical = 6.dp),525    ) {526        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)527    }528}529530// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted531// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.