EntryEditScreen.kt

18.2 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.animation.core.animateDpAsState4import androidx.compose.animation.core.tween5import androidx.compose.foundation.background6import androidx.compose.foundation.border7import androidx.compose.foundation.clickable8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.offset17import androidx.compose.foundation.layout.padding18import androidx.compose.foundation.layout.statusBarsPadding19import androidx.compose.foundation.layout.width20import androidx.compose.foundation.rememberScrollState21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.verticalScroll23import androidx.compose.material3.Slider24import androidx.compose.material3.SliderDefaults25import androidx.compose.material3.Text26import androidx.compose.runtime.Composable27import androidx.compose.runtime.getValue28import androidx.compose.runtime.mutableStateOf29import androidx.compose.runtime.remember30import androidx.compose.runtime.setValue31import androidx.compose.ui.Alignment32import androidx.compose.ui.Modifier33import androidx.compose.ui.draw.clip34import androidx.compose.ui.draw.clipToBounds35import androidx.compose.ui.graphics.Color36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.font.FontStyle38import androidx.compose.ui.text.font.FontWeight39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.text.input.VisualTransformation41import androidx.compose.ui.unit.dp42import androidx.compose.ui.unit.em43import androidx.compose.ui.unit.sp44import androidx.compose.runtime.collectAsState45import androidx.hilt.navigation.compose.hiltViewModel46import com.peachpasswords.ui.components.CopyButton47import com.peachpasswords.ui.theme.LocalPeachAccent48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.ui.theme.PeachMotion50import com.peachpasswords.ui.theme.PeachRadius51import com.peachpasswords.ui.theme.PeachType52import com.peachpasswords.ui.theme.ShellBackground5354@Composable55fun EntryEditScreen(56    id: String?,57    onBack: () -> Unit,58    onOpenNote: (String) -> Unit = {},59    vm: EntryEditViewModel = hiltViewModel(),60) {61    val f by vm.form.collectAsState()62    val usernameSuggestions by vm.usernameSuggestions.collectAsState()63    val emailSuggestions by vm.emailSuggestions.collectAsState()64    val accent = LocalPeachAccent.current6566    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.67    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(68        targetValue = editGhostWatermarkTarget(f.title),69        animationSpec = tween(300),70        label = "edit-watermark",71    )7273    ShellBackground {74        Column(Modifier.fillMaxSize().statusBarsPadding()) {75            Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {76                val canSave = canSaveEditForm(f)77                Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,78                    modifier = Modifier.align(Alignment.CenterStart)79                        .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))80                Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,81                    fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,82                    modifier = Modifier.align(Alignment.Center))83                Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,84                    modifier = Modifier.align(Alignment.CenterEnd)85                        .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))86            }8788            Box(Modifier.fillMaxSize().clipToBounds()) {89                // Ghost PeachScript watermark behind the form (matches the detail hero):90                // the full title, fading in as the user types.91                if (watermarkAlpha > 0f) {92                    Text(93                        text = ghostWatermarkText(f.title),94                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),95                        color = peachColors.T0.copy(alpha = watermarkAlpha),96                        maxLines = 1,97                        softWrap = false,98                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,99                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),100                    )101                }102                Column(103                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),104                ) {105                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {106                    listOf("login", "note", "card").forEach { t ->107                        EntryTypePill(t, f.type == t) {108                            // A brand-new note skips the form entirely and drops straight109                            // into the full-screen notes editor (brief Fix 6 item 7).110                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)111                            else vm.setType(t)112                        }113                    }114                }115                Spacer(Modifier.height(18.dp))116117                Field("title", f.title, vm::setTitle)118                if (f.type == "login") {119                    Field("url", f.url, vm::setUrl)120                    AdditionalUrlsEditor(121                        urls = f.additionalUrls,122                        onChange = vm::setAdditionalUrl,123                        onAdd = vm::addAdditionalUrl,124                        onRemove = vm::removeAdditionalUrl,125                    )126                    IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)127                    IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)128                    PasswordField(129                        value = f.password,130                        show = f.showPassword,131                        onChange = vm::setPassword,132                        onToggleShow = vm::toggleShowPassword,133                        onToggleGenerator = vm::toggleGenerator,134                    )135                    GeneratorDrawer(136                        open = f.generatorOpen,137                        length = f.genLength,138                        upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,139                        password = f.password,140                        copied = f.genCopied,141                        onLength = vm::setGenLength,142                        onUpper = vm::setGenUpper, onLower = vm::setGenLower,143                        onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,144                        onGenerate = vm::generate,145                        onCopy = vm::copyGenerated,146                    )147                    PasswordHistorySection(148                        history = f.history,149                        expanded = f.showHistory,150                        onToggle = vm::toggleHistory,151                        onRestore = vm::restorePassword,152                        onCopy = vm::copyHistoryPassword,153                    )154                    if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {155                        Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)156                    }157                }158                if (f.type == "card") {159                    CardEditFields(160                        cardNumber = f.cardNumber,161                        cardholderName = f.cardholderName,162                        expiry = f.expiry,163                        cvc = f.cvc,164                        showCvc = f.showCvc,165                        network = f.cardNetwork,166                        onCardNumber = vm::setCardNumber,167                        onCardholder = vm::setCardholderName,168                        onExpiry = vm::setExpiry,169                        onCvc = vm::setCvc,170                        onToggleShowCvc = vm::toggleShowCvc,171                    )172                }173                if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {174                    Field("notes", f.notes, vm::setNotes, singleLine = false)175                }176                if (f.type == "login") {177                    OptionalLoginAddLinks(178                        showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),179                        showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),180                        onAddTotp = vm::revealTotp,181                        onAddNotes = vm::revealNotes,182                    )183                }184                TagChipEditor(185                    tags = f.tags,186                    allTags = vm.allTags,187                    onAdd = vm::addTag,188                    onRemove = vm::removeTag,189                )190191                f.error?.let {192                    Spacer(Modifier.height(8.dp))193                    Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)194                }195                Spacer(Modifier.height(40.dp))196                }197            }198        }199    }200}201202@Composable203private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {204    val accent = LocalPeachAccent.current205    Box(206        modifier = Modifier207            .clip(RoundedCornerShape(PeachRadius.TypeTab))208            .border(209                1.dp,210                if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,211                RoundedCornerShape(PeachRadius.TypeTab),212            )213            .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)214            .clickable(onClick = onClick)215            .padding(horizontal = 12.dp, vertical = 5.dp),216    ) {217        Text(218            text = type.uppercase(),219            color = if (selected) accent else peachColors.T2,220            fontFamily = PeachType.Mono,221            fontSize = 9.sp,222            letterSpacing = 0.08.em,223        )224    }225}226227/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */228internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {229    !isNew -> "edit"230    type == "note" -> "new note"231    type == "card" -> "new card"232    else -> "new entry"233}234235internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {236    saving -> "saving..."237    isNew -> "create"238    else -> "save"239}240241internal fun canSaveEditForm(form: EditForm): Boolean {242    if (form.title.isBlank()) return false243    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false244    return true245}246247/** A brand-new note opens the full-screen notes editor instead of the form. */248internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"249250/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */251internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f252253internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =254    type == "login" && (revealed || totp.isNotBlank())255256internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =257    type == "login" && (revealed || notes.isNotBlank())258259internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =260    type == "login" && !shouldShowLoginTotp(type, revealed, totp)261262internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =263    type == "login" && !shouldShowLoginNotes(type, revealed, notes)264265@Composable266private fun OptionalLoginAddLinks(267    showTotpLink: Boolean,268    showNotesLink: Boolean,269    onAddTotp: () -> Unit,270    onAddNotes: () -> Unit,271) {272    if (!showTotpLink && !showNotesLink) return273    Row(274        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),275        horizontalArrangement = Arrangement.spacedBy(14.dp),276    ) {277        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)278        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)279    }280}281282@Composable283private fun AddFieldLink(label: String, onClick: () -> Unit) {284    Text(285        label,286        color = LocalPeachAccent.current,287        fontFamily = PeachType.Mono,288        fontSize = 11.sp,289        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),290    )291}292293@Composable294private fun Field(295    label: String,296    value: String,297    onChange: (String) -> Unit,298    singleLine: Boolean = true,299    transformation: VisualTransformation = VisualTransformation.None,300    trailing: (@Composable () -> Unit)? = null,301) {302    PeachField(303        label = label,304        value = value,305        onChange = onChange,306        singleLine = singleLine,307        mono = true,308        visualTransformation = transformation,309        trailing = trailing,310    )311}312313@Composable314private fun PasswordField(315    value: String,316    show: Boolean,317    onChange: (String) -> Unit,318    onToggleShow: () -> Unit,319    onToggleGenerator: () -> Unit,320) {321    Field(322        label = "password",323        value = value,324        onChange = onChange,325        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),326        trailing = {327            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,328                fontFamily = PeachType.Mono, fontSize = 9.sp,329                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))330            Text("⟳", color = LocalPeachAccent.current, fontSize = 16.sp,331                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))332        },333    )334}335336@Composable337private fun GeneratorDrawer(338    open: Boolean,339    length: Int,340    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,341    password: String,342    copied: Boolean,343    onLength: (Int) -> Unit,344    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,345    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,346    onGenerate: () -> Unit,347    onCopy: () -> Unit,348) {349    val height by animateDpAsState(350        targetValue = if (open) 234.dp else 0.dp,351        animationSpec = tween(320, easing = PeachMotion.Spring),352        label = "gen-drawer",353    )354    Box(355        Modifier.fillMaxWidth().height(height).clipToBounds()356            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),357    ) {358        GeneratorBody(359            length = length,360            upper = upper, lower = lower, numbers = numbers, symbols = symbols,361            password = password, copied = copied,362            onLength = onLength,363            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,364            onGenerate = onGenerate, onCopy = onCopy,365        )366    }367    if (open) Spacer(Modifier.height(14.dp))368}369370/**371 * Shared generator body — used by the inline drawer here and the detail372 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.373 */374@Composable375internal fun GeneratorBody(376    length: Int,377    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,378    password: String,379    copied: Boolean,380    onLength: (Int) -> Unit,381    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,382    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,383    onGenerate: () -> Unit,384    onCopy: () -> Unit,385) {386    val accent = LocalPeachAccent.current387    Column(Modifier.fillMaxWidth().padding(16.dp)) {388        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {389            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)390            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)391        }392        Slider(393            value = length.toFloat(),394            onValueChange = { onLength(it.toInt()) },395            valueRange = 8f..128f,396            colors = SliderDefaults.colors(397                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,398            ),399        )400        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {401            TogglePill("A-Z", upper, onUpper)402            TogglePill("a-z", lower, onLower)403            TogglePill("0-9", numbers, onNumbers)404            TogglePill("!@#", symbols, onSymbols)405        }406        Spacer(Modifier.height(12.dp))407        Row(verticalAlignment = Alignment.CenterVertically) {408            Box(409                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))410                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))411                    .background(accent.copy(alpha = 0.10f))412                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),413                contentAlignment = Alignment.Center,414            ) {415                Text("generate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)416            }417            Spacer(Modifier.width(8.dp))418            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })419        }420    }421}422423@Composable424private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {425    val accent = LocalPeachAccent.current426    Box(427        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))428            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))429            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)430            .clickable { onToggle(!on) }431            .padding(horizontal = 10.dp, vertical = 6.dp),432    ) {433        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)434    }435}436437// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted438// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.