EntryEditScreen.kt

20.4 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.text.style.TextOverflow42import androidx.compose.ui.unit.dp43import androidx.compose.ui.unit.em44import androidx.compose.ui.unit.sp45import androidx.compose.runtime.collectAsState46import androidx.hilt.navigation.compose.hiltViewModel47import com.peachpasswords.ui.components.CopyButton48import com.peachpasswords.ui.components.SecureFlagEffect49import com.peachpasswords.ui.theme.LocalPeachAccent50import com.peachpasswords.ui.theme.peachColors51import com.peachpasswords.ui.theme.PeachMotion52import com.peachpasswords.ui.theme.PeachRadius53import com.peachpasswords.ui.theme.PeachType54import com.peachpasswords.ui.theme.ShellBackground5556@Composable57fun EntryEditScreen(58    id: String?,59    onBack: () -> Unit,60    onOpenNote: (String) -> Unit = {},61    vm: EntryEditViewModel = hiltViewModel(),62) {63    SecureFlagEffect()64    val f by vm.form.collectAsState()65    val usernameSuggestions by vm.usernameSuggestions.collectAsState()66    val emailSuggestions by vm.emailSuggestions.collectAsState()67    val accent = LocalPeachAccent.current6869    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.70    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(71        targetValue = editGhostWatermarkTarget(f.title),72        animationSpec = tween(300),73        label = "edit-watermark",74    )7576    ShellBackground {77        Column(Modifier.fillMaxSize().statusBarsPadding()) {78            Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {79                val canSave = canSaveEditForm(f)80                Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,81                    modifier = Modifier.align(Alignment.CenterStart)82                        .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))83                Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,84                    fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,85                    modifier = Modifier.align(Alignment.Center))86                Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,87                    modifier = Modifier.align(Alignment.CenterEnd)88                        .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))89            }9091            Box(Modifier.fillMaxSize().clipToBounds()) {92                // Ghost PeachScript watermark behind the form (matches the detail hero):93                // the full title, fading in as the user types.94                if (watermarkAlpha > 0f) {95                    Text(96                        text = ghostWatermarkText(f.title),97                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),98                        color = peachColors.T0.copy(alpha = watermarkAlpha),99                        maxLines = 1,100                        softWrap = false,101                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,102                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),103                    )104                }105                Column(106                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),107                ) {108                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {109                    listOf("login", "note", "card").forEach { t ->110                        EntryTypePill(t, f.type == t) {111                            // A brand-new note skips the form entirely and drops straight112                            // into the full-screen notes editor (brief Fix 6 item 7).113                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)114                            else vm.setType(t)115                        }116                    }117                }118                Spacer(Modifier.height(18.dp))119120                Field("title", f.title, vm::setTitle)121                if (f.type == "login") {122                    Field("url", f.url, vm::setUrl)123                    AdditionalUrlsEditor(124                        urls = f.additionalUrls,125                        onChange = vm::setAdditionalUrl,126                        onAdd = vm::addAdditionalUrl,127                        onRemove = vm::removeAdditionalUrl,128                    )129                    IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)130                    IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)131                    PasswordField(132                        value = f.password,133                        show = f.showPassword,134                        generatorOpen = f.generatorOpen,135                        onChange = vm::setPassword,136                        onToggleShow = vm::toggleShowPassword,137                        onToggleGenerator = vm::toggleGenerator,138                    )139                    GeneratorDrawer(140                        open = f.generatorOpen,141                        length = f.genLength,142                        upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,143                        password = f.password,144                        copied = f.genCopied,145                        onLength = vm::setGenLength,146                        onUpper = vm::setGenUpper, onLower = vm::setGenLower,147                        onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,148                        onGenerate = vm::generate,149                        onCopy = vm::copyGenerated,150                        onUse = vm::useGeneratedPassword,151                    )152                    PasswordHistorySection(153                        history = f.history,154                        expanded = f.showHistory,155                        onToggle = vm::toggleHistory,156                        onRestore = vm::restorePassword,157                        onCopy = vm::copyHistoryPassword,158                    )159                    if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {160                        Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)161                    }162                }163                if (f.type == "card") {164                    CardEditFields(165                        cardNickname = f.cardNickname,166                        cardNumber = f.cardNumber,167                        cardholderName = f.cardholderName,168                        expiry = f.expiry,169                        cvc = f.cvc,170                        showCvc = f.showCvc,171                        network = f.cardNetwork,172                        onCardNickname = vm::setCardNickname,173                        onCardNumber = vm::setCardNumber,174                        onCardholder = vm::setCardholderName,175                        onExpiry = vm::setExpiry,176                        onCvc = vm::setCvc,177                        onToggleShowCvc = vm::toggleShowCvc,178                    )179                }180                if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {181                    Field("notes", f.notes, vm::setNotes, singleLine = false)182                }183                if (f.type == "login") {184                    OptionalLoginAddLinks(185                        showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),186                        showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),187                        onAddTotp = vm::revealTotp,188                        onAddNotes = vm::revealNotes,189                    )190                }191                TagChipEditor(192                    tags = f.tags,193                    allTags = vm.allTags,194                    onAdd = vm::addTag,195                    onRemove = vm::removeTag,196                )197198                f.error?.let {199                    Spacer(Modifier.height(8.dp))200                    Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)201                }202                Spacer(Modifier.height(40.dp))203                }204            }205        }206    }207}208209@Composable210private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {211    val accent = LocalPeachAccent.current212    Box(213        modifier = Modifier214            .clip(RoundedCornerShape(PeachRadius.TypeTab))215            .border(216                1.dp,217                if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,218                RoundedCornerShape(PeachRadius.TypeTab),219            )220            .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)221            .clickable(onClick = onClick)222            .padding(horizontal = 12.dp, vertical = 5.dp),223    ) {224        Text(225            text = type.uppercase(),226            color = if (selected) accent else peachColors.T2,227            fontFamily = PeachType.Mono,228            fontSize = 9.sp,229            letterSpacing = 0.08.em,230        )231    }232}233234/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */235internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {236    !isNew -> "edit"237    type == "note" -> "new note"238    type == "card" -> "new card"239    else -> "new entry"240}241242internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {243    saving -> "saving..."244    isNew -> "create"245    else -> "save"246}247248internal fun canSaveEditForm(form: EditForm): Boolean {249    if (form.title.isBlank()) return false250    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false251    return true252}253254/** A brand-new note opens the full-screen notes editor instead of the form. */255internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"256257/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */258internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f259260internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =261    type == "login" && (revealed || totp.isNotBlank())262263internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =264    type == "login" && (revealed || notes.isNotBlank())265266internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =267    type == "login" && !shouldShowLoginTotp(type, revealed, totp)268269internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =270    type == "login" && !shouldShowLoginNotes(type, revealed, notes)271272@Composable273private fun OptionalLoginAddLinks(274    showTotpLink: Boolean,275    showNotesLink: Boolean,276    onAddTotp: () -> Unit,277    onAddNotes: () -> Unit,278) {279    if (!showTotpLink && !showNotesLink) return280    Row(281        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),282        horizontalArrangement = Arrangement.spacedBy(14.dp),283    ) {284        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)285        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)286    }287}288289@Composable290private fun AddFieldLink(label: String, onClick: () -> Unit) {291    Text(292        label,293        color = LocalPeachAccent.current,294        fontFamily = PeachType.Mono,295        fontSize = 11.sp,296        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),297    )298}299300@Composable301private fun Field(302    label: String,303    value: String,304    onChange: (String) -> Unit,305    singleLine: Boolean = true,306    transformation: VisualTransformation = VisualTransformation.None,307    trailing: (@Composable () -> Unit)? = null,308) {309    PeachField(310        label = label,311        value = value,312        onChange = onChange,313        singleLine = singleLine,314        mono = true,315        visualTransformation = transformation,316        trailing = trailing,317    )318}319320@Composable321private fun PasswordField(322    value: String,323    show: Boolean,324    generatorOpen: Boolean,325    onChange: (String) -> Unit,326    onToggleShow: () -> Unit,327    onToggleGenerator: () -> Unit,328) {329    Field(330        label = "password",331        value = value,332        onChange = onChange,333        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),334        trailing = {335            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,336                fontFamily = PeachType.Mono, fontSize = 9.sp,337                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))338            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,339                fontFamily = PeachType.Mono, fontSize = 9.sp,340                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))341        },342    )343}344345internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"346347@Composable348private fun GeneratorDrawer(349    open: Boolean,350    length: Int,351    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,352    password: String,353    copied: Boolean,354    onLength: (Int) -> Unit,355    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,356    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,357    onGenerate: () -> Unit,358    onCopy: () -> Unit,359    onUse: () -> Unit,360) {361    val height by animateDpAsState(362        targetValue = if (open) 326.dp else 0.dp,363        animationSpec = tween(320, easing = PeachMotion.Spring),364        label = "gen-drawer",365    )366    Box(367        Modifier.fillMaxWidth().height(height).clipToBounds()368            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),369    ) {370        GeneratorBody(371            length = length,372            upper = upper, lower = lower, numbers = numbers, symbols = symbols,373            password = password, copied = copied,374            onLength = onLength,375            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,376            onGenerate = onGenerate, onCopy = onCopy,377            showOutput = true,378            onUse = onUse,379        )380    }381    if (open) Spacer(Modifier.height(14.dp))382}383384/**385 * Shared generator body — used by the inline drawer here and the detail386 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.387 */388@Composable389internal fun GeneratorBody(390    length: Int,391    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,392    password: String,393    copied: Boolean,394    onLength: (Int) -> Unit,395    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,396    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,397    onGenerate: () -> Unit,398    onCopy: () -> Unit,399    showOutput: Boolean = false,400    onUse: (() -> Unit)? = null,401) {402    val accent = LocalPeachAccent.current403    Column(Modifier.fillMaxWidth().padding(16.dp)) {404        if (showOutput) {405            Box(406                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))407                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))408                    .background(accent.copy(alpha = 0.08f))409                    .padding(horizontal = 12.dp, vertical = 10.dp),410            ) {411                Text(412                    text = password.ifEmpty { "tap generate" },413                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,414                    fontFamily = PeachType.Mono,415                    fontSize = 11.sp,416                    maxLines = 2,417                    overflow = TextOverflow.Ellipsis,418                )419            }420            Spacer(Modifier.height(10.dp))421        }422        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {423            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)424            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)425        }426        Slider(427            value = length.toFloat(),428            onValueChange = { onLength(it.toInt()) },429            valueRange = 8f..128f,430            colors = SliderDefaults.colors(431                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,432            ),433        )434        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {435            TogglePill("A-Z", upper, onUpper)436            TogglePill("a-z", lower, onLower)437            TogglePill("0-9", numbers, onNumbers)438            TogglePill("!@#", symbols, onSymbols)439        }440        Spacer(Modifier.height(12.dp))441        Row(verticalAlignment = Alignment.CenterVertically) {442            Box(443                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))444                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))445                    .background(accent.copy(alpha = 0.10f))446                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),447                contentAlignment = Alignment.Center,448            ) {449                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)450            }451            Spacer(Modifier.width(8.dp))452            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })453        }454        onUse?.let {455            Spacer(Modifier.height(10.dp))456            Box(457                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))458                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))459                    .background(accent.copy(alpha = 0.14f))460                    .clickable(enabled = password.isNotEmpty(), onClick = it)461                    .padding(vertical = 12.dp),462                contentAlignment = Alignment.Center,463            ) {464                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)465            }466        }467    }468}469470@Composable471private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {472    val accent = LocalPeachAccent.current473    Box(474        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))475            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))476            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)477            .clickable { onToggle(!on) }478            .padding(horizontal = 10.dp, vertical = 6.dp),479    ) {480        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)481    }482}483484// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted485// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.