EntryEditScreen.kt

17.6 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 accent = LocalPeachAccent.current6465    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.66    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(67        targetValue = editGhostWatermarkTarget(f.title),68        animationSpec = tween(300),69        label = "edit-watermark",70    )7172    ShellBackground {73        Column(Modifier.fillMaxSize().statusBarsPadding()) {74            Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {75                Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,76                    modifier = Modifier.align(Alignment.CenterStart)77                        .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))78                Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,79                    fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,80                    modifier = Modifier.align(Alignment.Center))81                Text("save", color = accent, fontFamily = PeachType.Mono, fontSize = 11.sp,82                    modifier = Modifier.align(Alignment.CenterEnd)83                        .clip(RoundedCornerShape(8.dp)).clickable { vm.save(onBack) }.padding(8.dp))84            }8586            Box(Modifier.fillMaxSize().clipToBounds()) {87                // Ghost PeachScript watermark behind the form (matches the detail hero):88                // the full title, fading in as the user types.89                if (watermarkAlpha > 0f) {90                    Text(91                        text = ghostWatermarkText(f.title),92                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),93                        color = peachColors.T0.copy(alpha = watermarkAlpha),94                        maxLines = 1,95                        softWrap = false,96                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,97                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),98                    )99                }100                Column(101                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),102                ) {103                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {104                    listOf("login", "note", "card").forEach { t ->105                        EntryTypePill(t, f.type == t) {106                            // A brand-new note skips the form entirely and drops straight107                            // into the full-screen notes editor (brief Fix 6 item 7).108                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)109                            else vm.setType(t)110                        }111                    }112                }113                Spacer(Modifier.height(18.dp))114115                Field("title", f.title, vm::setTitle)116                if (f.type == "login") {117                    Field("url", f.url, vm::setUrl)118                    AdditionalUrlsEditor(119                        urls = f.additionalUrls,120                        onChange = vm::setAdditionalUrl,121                        onAdd = vm::addAdditionalUrl,122                        onRemove = vm::removeAdditionalUrl,123                    )124                    IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)125                    IdentitySuggestField("email", f.email, usernameSuggestions, vm::setEmail)126                    PasswordField(127                        value = f.password,128                        show = f.showPassword,129                        onChange = vm::setPassword,130                        onToggleShow = vm::toggleShowPassword,131                        onToggleGenerator = vm::toggleGenerator,132                    )133                    GeneratorDrawer(134                        open = f.generatorOpen,135                        length = f.genLength,136                        upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,137                        password = f.password,138                        copied = f.genCopied,139                        onLength = vm::setGenLength,140                        onUpper = vm::setGenUpper, onLower = vm::setGenLower,141                        onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,142                        onGenerate = vm::generate,143                        onCopy = vm::copyGenerated,144                    )145                    PasswordHistorySection(146                        history = f.history,147                        expanded = f.showHistory,148                        onToggle = vm::toggleHistory,149                        onRestore = vm::restorePassword,150                        onCopy = vm::copyHistoryPassword,151                    )152                    if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {153                        Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)154                    }155                }156                if (f.type == "card") {157                    CardEditFields(158                        cardNumber = f.cardNumber,159                        cardholderName = f.cardholderName,160                        expiry = f.expiry,161                        cvc = f.cvc,162                        showCvc = f.showCvc,163                        network = f.cardNetwork,164                        onCardNumber = vm::setCardNumber,165                        onCardholder = vm::setCardholderName,166                        onExpiry = vm::setExpiry,167                        onCvc = vm::setCvc,168                        onToggleShowCvc = vm::toggleShowCvc,169                    )170                }171                if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {172                    Field("notes", f.notes, vm::setNotes, singleLine = false)173                }174                if (f.type == "login") {175                    OptionalLoginAddLinks(176                        showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),177                        showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),178                        onAddTotp = vm::revealTotp,179                        onAddNotes = vm::revealNotes,180                    )181                }182                TagChipEditor(183                    tags = f.tags,184                    allTags = vm.allTags,185                    onAdd = vm::addTag,186                    onRemove = vm::removeTag,187                )188189                f.error?.let {190                    Spacer(Modifier.height(8.dp))191                    Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)192                }193                Spacer(Modifier.height(40.dp))194                }195            }196        }197    }198}199200@Composable201private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {202    val accent = LocalPeachAccent.current203    Box(204        modifier = Modifier205            .clip(RoundedCornerShape(PeachRadius.TypeTab))206            .border(207                1.dp,208                if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,209                RoundedCornerShape(PeachRadius.TypeTab),210            )211            .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)212            .clickable(onClick = onClick)213            .padding(horizontal = 12.dp, vertical = 5.dp),214    ) {215        Text(216            text = type.uppercase(),217            color = if (selected) accent else peachColors.T2,218            fontFamily = PeachType.Mono,219            fontSize = 9.sp,220            letterSpacing = 0.08.em,221        )222    }223}224225/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */226internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {227    !isNew -> "edit"228    type == "note" -> "new note"229    type == "card" -> "new card"230    else -> "new entry"231}232233/** A brand-new note opens the full-screen notes editor instead of the form. */234internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"235236/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */237internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f238239internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =240    type == "login" && (revealed || totp.isNotBlank())241242internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =243    type == "login" && (revealed || notes.isNotBlank())244245internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =246    type == "login" && !shouldShowLoginTotp(type, revealed, totp)247248internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =249    type == "login" && !shouldShowLoginNotes(type, revealed, notes)250251@Composable252private fun OptionalLoginAddLinks(253    showTotpLink: Boolean,254    showNotesLink: Boolean,255    onAddTotp: () -> Unit,256    onAddNotes: () -> Unit,257) {258    if (!showTotpLink && !showNotesLink) return259    Row(260        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),261        horizontalArrangement = Arrangement.spacedBy(14.dp),262    ) {263        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)264        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)265    }266}267268@Composable269private fun AddFieldLink(label: String, onClick: () -> Unit) {270    Text(271        label,272        color = LocalPeachAccent.current,273        fontFamily = PeachType.Mono,274        fontSize = 11.sp,275        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),276    )277}278279@Composable280private fun Field(281    label: String,282    value: String,283    onChange: (String) -> Unit,284    singleLine: Boolean = true,285    transformation: VisualTransformation = VisualTransformation.None,286    trailing: (@Composable () -> Unit)? = null,287) {288    PeachField(289        label = label,290        value = value,291        onChange = onChange,292        singleLine = singleLine,293        mono = true,294        visualTransformation = transformation,295        trailing = trailing,296    )297}298299@Composable300private fun PasswordField(301    value: String,302    show: Boolean,303    onChange: (String) -> Unit,304    onToggleShow: () -> Unit,305    onToggleGenerator: () -> Unit,306) {307    Field(308        label = "password",309        value = value,310        onChange = onChange,311        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),312        trailing = {313            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,314                fontFamily = PeachType.Mono, fontSize = 9.sp,315                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))316            Text("⟳", color = LocalPeachAccent.current, fontSize = 16.sp,317                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))318        },319    )320}321322@Composable323private fun GeneratorDrawer(324    open: Boolean,325    length: Int,326    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,327    password: String,328    copied: Boolean,329    onLength: (Int) -> Unit,330    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,331    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,332    onGenerate: () -> Unit,333    onCopy: () -> Unit,334) {335    val height by animateDpAsState(336        targetValue = if (open) 234.dp else 0.dp,337        animationSpec = tween(320, easing = PeachMotion.Spring),338        label = "gen-drawer",339    )340    Box(341        Modifier.fillMaxWidth().height(height).clipToBounds()342            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),343    ) {344        GeneratorBody(345            length = length,346            upper = upper, lower = lower, numbers = numbers, symbols = symbols,347            password = password, copied = copied,348            onLength = onLength,349            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,350            onGenerate = onGenerate, onCopy = onCopy,351        )352    }353    if (open) Spacer(Modifier.height(14.dp))354}355356/**357 * Shared generator body — used by the inline drawer here and the detail358 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.359 */360@Composable361internal fun GeneratorBody(362    length: Int,363    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,364    password: String,365    copied: Boolean,366    onLength: (Int) -> Unit,367    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,368    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,369    onGenerate: () -> Unit,370    onCopy: () -> Unit,371) {372    val accent = LocalPeachAccent.current373    Column(Modifier.fillMaxWidth().padding(16.dp)) {374        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {375            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)376            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)377        }378        Slider(379            value = length.toFloat(),380            onValueChange = { onLength(it.toInt()) },381            valueRange = 8f..128f,382            colors = SliderDefaults.colors(383                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,384            ),385        )386        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {387            TogglePill("A-Z", upper, onUpper)388            TogglePill("a-z", lower, onLower)389            TogglePill("0-9", numbers, onNumbers)390            TogglePill("!@#", symbols, onSymbols)391        }392        Spacer(Modifier.height(12.dp))393        Row(verticalAlignment = Alignment.CenterVertically) {394            Box(395                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))396                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))397                    .background(accent.copy(alpha = 0.10f))398                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),399                contentAlignment = Alignment.Center,400            ) {401                Text("generate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)402            }403            Spacer(Modifier.width(8.dp))404            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })405        }406    }407}408409@Composable410private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {411    val accent = LocalPeachAccent.current412    Box(413        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))414            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))415            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)416            .clickable { onToggle(!on) }417            .padding(horizontal = 10.dp, vertical = 6.dp),418    ) {419        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)420    }421}422423// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted424// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.