EntryEditScreen.kt

20.7 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.FontWeight38import androidx.compose.ui.text.input.PasswordVisualTransformation39import androidx.compose.ui.text.input.VisualTransformation40import androidx.compose.ui.text.style.TextOverflow41import 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.components.PeachEmptyOrEndState48import com.peachpasswords.ui.components.PeachSegment49import com.peachpasswords.ui.components.PeachSegmentedControl50import com.peachpasswords.ui.components.SecureFlagEffect51import com.peachpasswords.ui.components.PeachTopBar52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.PeachMotion54import com.peachpasswords.ui.theme.PeachRadius55import com.peachpasswords.ui.theme.PeachSpacing56import com.peachpasswords.ui.theme.PeachType57import com.peachpasswords.ui.theme.ShellBackground58import com.peachpasswords.ui.theme.peachColors5960@Composable61fun EntryEditScreen(62    id: String?,63    onBack: () -> Unit,64    onOpenNote: (String) -> Unit = {},65    vm: EntryEditViewModel = hiltViewModel(),66) {67    SecureFlagEffect()6869    val f by vm.form.collectAsState()70    val usernameSuggestions by vm.usernameSuggestions.collectAsState()71    val emailSuggestions by vm.emailSuggestions.collectAsState()7273    // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.74    val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(75        targetValue = editGhostWatermarkTarget(f.title),76        animationSpec = tween(300),77        label = "edit-watermark",78    )7980    ShellBackground {81        Column(82            Modifier83                .fillMaxSize()84                .statusBarsPadding()85                .padding(horizontal = PeachSpacing.xl),86        ) {87            EntryEditTopBar(88                title = editTitleBarLabel(vm.isNew, f.type),89                saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),90                canSave = canSaveEditForm(f) && !f.saving,91                onCancel = onBack,92                onSave = { vm.save(onBack) },93            )9495            Spacer(Modifier.height(PeachSpacing.lg))9697            Box(Modifier.fillMaxSize().clipToBounds()) {98                // Ghost PeachScript watermark behind the form (matches the detail hero):99                // the full title, fading in as the user types.100                if (watermarkAlpha > 0f) {101                    Text(102                        text = ghostWatermarkText(f.title),103                        style = TextStyle(fontFamily = PeachType.Script, fontSize = 140.sp),104                        color = peachColors.T0.copy(alpha = watermarkAlpha),105                        maxLines = 1,106                        softWrap = false,107                        overflow = androidx.compose.ui.text.style.TextOverflow.Clip,108                        modifier = Modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 8.dp),109                    )110                }111                Column(112                    Modifier.fillMaxSize().verticalScroll(rememberScrollState()),113                ) {114                    PeachSegmentedControl(115                        segments = listOf(116                            PeachSegment("login", "login"),117                            PeachSegment("note", "note"),118                            PeachSegment("card", "card"),119                        ),120                        selectedKey = f.type,121                        onSelected = { t ->122                            // A brand-new note skips the form entirely and drops straight123                            // into the full-screen notes editor (brief Fix 6 item 7).124                            if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)125                            else vm.setType(t)126                        },127                    )128                    Spacer(Modifier.height(PeachSpacing.lg))129130                    Field("title", f.title, vm::setTitle)131                    if (f.type == "login") {132                        Field("url", f.url, vm::setUrl)133                        AdditionalUrlsEditor(134                            urls = f.additionalUrls,135                            onChange = vm::setAdditionalUrl,136                            onAdd = vm::addAdditionalUrl,137                            onRemove = vm::removeAdditionalUrl,138                        )139                        IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)140                        IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)141                        PasswordField(142                            value = f.password,143                            show = f.showPassword,144                            generatorOpen = f.generatorOpen,145                            onChange = vm::setPassword,146                            onToggleShow = vm::toggleShowPassword,147                            onToggleGenerator = vm::toggleGenerator,148                        )149                        GeneratorDrawer(150                            open = f.generatorOpen,151                            length = f.genLength,152                            upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,153                            password = f.password,154                            copied = f.genCopied,155                            onLength = vm::setGenLength,156                            onUpper = vm::setGenUpper, onLower = vm::setGenLower,157                            onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,158                            onGenerate = vm::generate,159                            onCopy = vm::copyGenerated,160                            onUse = vm::useGeneratedPassword,161                        )162                        PasswordHistorySection(163                            history = f.history,164                            expanded = f.showHistory,165                            onToggle = vm::toggleHistory,166                            onRestore = vm::restorePassword,167                            onCopy = vm::copyHistoryPassword,168                        )169                        if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {170                            Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)171                        }172                    }173                    if (f.type == "card") {174                        CardEditFields(175                            cardNickname = f.cardNickname,176                            cardNumber = f.cardNumber,177                            cardholderName = f.cardholderName,178                            expiry = f.expiry,179                            cvc = f.cvc,180                            showCvc = f.showCvc,181                            network = f.cardNetwork,182                            onCardNickname = vm::setCardNickname,183                            onCardNumber = vm::setCardNumber,184                            onCardholder = vm::setCardholderName,185                            onExpiry = vm::setExpiry,186                            onCvc = vm::setCvc,187                            onToggleShowCvc = vm::toggleShowCvc,188                        )189                    }190                    if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {191                        Field("notes", f.notes, vm::setNotes, singleLine = false)192                    }193                    if (f.type == "login") {194                        OptionalLoginAddLinks(195                            showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),196                            showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),197                            onAddTotp = vm::revealTotp,198                            onAddNotes = vm::revealNotes,199                        )200                    }201                    TagChipEditor(202                        tags = f.tags,203                        allTags = vm.allTags,204                        onAdd = vm::addTag,205                        onRemove = vm::removeTag,206                    )207208                    f.error?.let {209                        Spacer(Modifier.height(PeachSpacing.sm))210                        Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)211                    }212                    Spacer(Modifier.height(PeachSpacing.xl))213                }214            }215        }216    }217}218219@Composable220private fun EntryEditTopBar(221    title: String,222    saveLabel: String,223    canSave: Boolean,224    onCancel: () -> Unit,225    onSave: () -> Unit,226) {227    val accent = LocalPeachAccent.current228    PeachTopBar(229        title = title,230        leading = {231            Text(232                "cancel",233                color = peachColors.T2,234                fontFamily = PeachType.Mono,235                fontSize = 11.sp,236                modifier = Modifier237                    .clip(RoundedCornerShape(8.dp))238                    .clickable(onClick = onCancel)239                    .padding(PeachSpacing.sm),240            )241        },242        actions = {243            Text(244                saveLabel,245                color = if (canSave) accent else peachColors.T2,246                fontFamily = PeachType.Mono,247                fontSize = 11.sp,248                modifier = Modifier249                    .clip(RoundedCornerShape(8.dp))250                    .clickable(enabled = canSave, onClick = onSave)251                    .padding(PeachSpacing.sm),252            )253        },254    )255}256257/** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */258internal fun editTitleBarLabel(isNew: Boolean, type: String): String = when {259    !isNew -> "edit"260    type == "note" -> "new note"261    type == "card" -> "new card"262    else -> "new entry"263}264265internal fun entryEditPrimaryActionLabel(isNew: Boolean, saving: Boolean): String = when {266    saving -> "saving..."267    isNew -> "create"268    else -> "save"269}270271internal fun canSaveEditForm(form: EditForm): Boolean {272    if (form.title.isBlank()) return false273    if (form.type == "card" && (form.cardholderName.isBlank() || form.cardNumber.isBlank())) return false274    return true275}276277/** A brand-new note opens the full-screen notes editor instead of the form. */278internal fun shouldOpenNotesMode(isNew: Boolean, type: String): Boolean = isNew && type == "note"279280/** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */281internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f282283internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =284    type == "login" && (revealed || totp.isNotBlank())285286internal fun shouldShowLoginNotes(type: String, revealed: Boolean, notes: String): Boolean =287    type == "login" && (revealed || notes.isNotBlank())288289internal fun shouldShowAddTotpLink(type: String, revealed: Boolean, totp: String): Boolean =290    type == "login" && !shouldShowLoginTotp(type, revealed, totp)291292internal fun shouldShowAddNotesLink(type: String, revealed: Boolean, notes: String): Boolean =293    type == "login" && !shouldShowLoginNotes(type, revealed, notes)294295@Composable296private fun OptionalLoginAddLinks(297    showTotpLink: Boolean,298    showNotesLink: Boolean,299    onAddTotp: () -> Unit,300    onAddNotes: () -> Unit,301) {302    if (!showTotpLink && !showNotesLink) return303    Row(304        Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),305        horizontalArrangement = Arrangement.spacedBy(14.dp),306    ) {307        if (showTotpLink) AddFieldLink("+ add 2FA", onAddTotp)308        if (showNotesLink) AddFieldLink("+ add notes", onAddNotes)309    }310}311312@Composable313private fun AddFieldLink(label: String, onClick: () -> Unit) {314    Text(315        label,316        color = LocalPeachAccent.current,317        fontFamily = PeachType.Mono,318        fontSize = 11.sp,319        modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onClick).padding(vertical = 6.dp),320    )321}322323@Composable324private fun Field(325    label: String,326    value: String,327    onChange: (String) -> Unit,328    singleLine: Boolean = true,329    transformation: VisualTransformation = VisualTransformation.None,330    trailing: (@Composable () -> Unit)? = null,331) {332    PeachField(333        label = label,334        value = value,335        onChange = onChange,336        singleLine = singleLine,337        mono = true,338        visualTransformation = transformation,339        trailing = trailing,340    )341}342343@Composable344private fun PasswordField(345    value: String,346    show: Boolean,347    generatorOpen: Boolean,348    onChange: (String) -> Unit,349    onToggleShow: () -> Unit,350    onToggleGenerator: () -> Unit,351) {352    Field(353        label = "password",354        value = value,355        onChange = onChange,356        transformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),357        trailing = {358            Text(if (show) "hide" else "show", color = LocalPeachAccent.current,359                fontFamily = PeachType.Mono, fontSize = 9.sp,360                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleShow).padding(6.dp))361            Text(generatorToggleLabel(generatorOpen), color = LocalPeachAccent.current,362                fontFamily = PeachType.Mono, fontSize = 9.sp,363                modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onToggleGenerator).padding(6.dp))364        },365    )366}367368internal fun generatorToggleLabel(open: Boolean): String = if (open) "hide generator" else "generate"369370@Composable371private fun GeneratorDrawer(372    open: Boolean,373    length: Int,374    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,375    password: String,376    copied: Boolean,377    onLength: (Int) -> Unit,378    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,379    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,380    onGenerate: () -> Unit,381    onCopy: () -> Unit,382    onUse: () -> Unit,383) {384    val height by animateDpAsState(385        targetValue = if (open) 326.dp else 0.dp,386        animationSpec = tween(320, easing = PeachMotion.Spring),387        label = "gen-drawer",388    )389    Box(390        Modifier.fillMaxWidth().height(height).clipToBounds()391            .clip(RoundedCornerShape(12.dp)).background(peachColors.S2),392    ) {393        GeneratorBody(394            length = length,395            upper = upper, lower = lower, numbers = numbers, symbols = symbols,396            password = password, copied = copied,397            onLength = onLength,398            onUpper = onUpper, onLower = onLower, onNumbers = onNumbers, onSymbols = onSymbols,399            onGenerate = onGenerate, onCopy = onCopy,400            showOutput = true,401            onUse = onUse,402        )403    }404    if (open) Spacer(Modifier.height(14.dp))405}406407/**408 * Shared generator body — used by the inline drawer here and the detail409 * ModalBottomSheet (D4). Stateless; the generate row carries the [Copy] drain.410 */411@Composable412internal fun GeneratorBody(413    length: Int,414    upper: Boolean, lower: Boolean, numbers: Boolean, symbols: Boolean,415    password: String,416    copied: Boolean,417    onLength: (Int) -> Unit,418    onUpper: (Boolean) -> Unit, onLower: (Boolean) -> Unit,419    onNumbers: (Boolean) -> Unit, onSymbols: (Boolean) -> Unit,420    onGenerate: () -> Unit,421    onCopy: () -> Unit,422    showOutput: Boolean = false,423    onUse: (() -> Unit)? = null,424) {425    val accent = LocalPeachAccent.current426    Column(Modifier.fillMaxWidth().padding(16.dp)) {427        if (showOutput) {428            Box(429                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))430                    .border(1.dp, accent.copy(alpha = 0.45f), RoundedCornerShape(10.dp))431                    .background(accent.copy(alpha = 0.08f))432                    .padding(horizontal = 12.dp, vertical = 10.dp),433            ) {434                Text(435                    text = password.ifEmpty { "tap generate" },436                    color = if (password.isEmpty()) peachColors.T2 else peachColors.T0,437                    fontFamily = PeachType.Mono,438                    fontSize = 11.sp,439                    maxLines = 2,440                    overflow = TextOverflow.Ellipsis,441                )442            }443            Spacer(Modifier.height(10.dp))444        }445        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {446            Text("GENERATOR", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 8.sp)447            Text("length $length", color = accent, fontFamily = PeachType.Mono, fontSize = 9.sp)448        }449        Slider(450            value = length.toFloat(),451            onValueChange = { onLength(it.toInt()) },452            valueRange = 8f..128f,453            colors = SliderDefaults.colors(454                thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = peachColors.T3,455            ),456        )457        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {458            TogglePill("A-Z", upper, onUpper)459            TogglePill("a-z", lower, onLower)460            TogglePill("0-9", numbers, onNumbers)461            TogglePill("!@#", symbols, onSymbols)462        }463        Spacer(Modifier.height(12.dp))464        Row(verticalAlignment = Alignment.CenterVertically) {465            Box(466                Modifier.weight(1f).clip(RoundedCornerShape(10.dp))467                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))468                    .background(accent.copy(alpha = 0.10f))469                    .clickable(onClick = onGenerate).padding(vertical = 12.dp),470                contentAlignment = Alignment.Center,471            ) {472                Text(if (password.isEmpty()) "generate" else "regenerate", color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)473            }474            Spacer(Modifier.width(8.dp))475            CopyButton(copied = copied, onCopy = { if (password.isNotEmpty()) onCopy() })476        }477        onUse?.let {478            Spacer(Modifier.height(10.dp))479            Box(480                Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))481                    .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(10.dp))482                    .background(accent.copy(alpha = 0.14f))483                    .clickable(enabled = password.isNotEmpty(), onClick = it)484                    .padding(vertical = 12.dp),485                contentAlignment = Alignment.Center,486            ) {487                Text("use", color = if (password.isEmpty()) accent.copy(alpha = 0.45f) else accent, fontFamily = PeachType.Mono, fontSize = 12.sp)488            }489        }490    }491}492493@Composable494private fun TogglePill(label: String, on: Boolean, onToggle: (Boolean) -> Unit) {495    val accent = LocalPeachAccent.current496    Box(497        Modifier.clip(RoundedCornerShape(PeachRadius.TypeTab))498            .border(1.dp, if (on) accent.copy(alpha = 0.5f) else peachColors.T3, RoundedCornerShape(PeachRadius.TypeTab))499            .background(if (on) accent.copy(alpha = 0.10f) else Color.Transparent)500            .clickable { onToggle(!on) }501            .padding(horizontal = 10.dp, vertical = 6.dp),502    ) {503        Text(label, color = if (on) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp)504    }505}506507// PasswordHistorySection / HistoryRow / HistoryAction / timeSince were extracted508// to ui/vault/PasswordHistorySection.kt (D7) so EntryDetailScreen can reuse them.