package com.peachpasswords.ui.vault import android.content.Context import com.peachpasswords.shared.PeachClipboard import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.vault.PasswordGenerator import com.peachpasswords.vault.PasswordHistoryItem import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject data class EditForm( val type: String = "login", val title: String = "", val username: String = "", val email: String = "", val password: String = "", val url: String = "", val additionalUrls: List = emptyList(), val totp: String = "", val notes: String = "", val cardNumber: String = "", val cardholderName: String = "", val expiry: String = "", // "MM/YY"; split into expMonth/expYear on save val cvc: String = "", val showCvc: Boolean = false, val cardNetwork: String = "unknown", val tags: List = emptyList(), val showPassword: Boolean = false, val generatorOpen: Boolean = false, val genCopied: Boolean = false, val history: List = emptyList(), val showHistory: Boolean = false, val genLength: Int = 20, val genUpper: Boolean = true, val genLower: Boolean = true, val genNumbers: Boolean = true, val genSymbols: Boolean = true, val saving: Boolean = false, val error: String? = null, val showTotp: Boolean = false, val showNotes: Boolean = false, ) @HiltViewModel class EntryEditViewModel @Inject constructor( private val vault: VaultRepository, @ApplicationContext private val appContext: Context, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val id: String? = savedStateHandle.get("id")?.takeIf { it.isNotBlank() } val isNew: Boolean = id == null private val existing: VaultEntry? = id?.let { vault.getEntry(it) } private val _form = MutableStateFlow( existing?.let { EditForm( type = it.type ?: "login", title = it.title, username = it.username.orEmpty(), email = it.email.orEmpty(), password = it.password.orEmpty(), url = it.url.orEmpty(), additionalUrls = it.additionalUrls.orEmpty(), totp = it.totp.orEmpty(), notes = it.notes.orEmpty(), cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()), cardholderName = it.cardholderName.orEmpty(), expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear), cvc = it.cvc.orEmpty(), cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()), tags = it.tags.orEmpty(), history = it.passwordHistory.orEmpty(), showTotp = !it.totp.isNullOrBlank(), showNotes = !it.notes.isNullOrBlank(), ) } ?: EditForm(), ) val form: StateFlow = _form.asStateFlow() /** * Up to 4 previously-used identity values, frequency-sorted. Username and * email share one field in the Peach model, so both editor fields suggest * from "username". */ val usernameSuggestions: StateFlow> = MutableStateFlow(vault.getDistinctFieldValues("username").take(4)).asStateFlow() fun setType(t: String) = _form.update { it.copy(type = t) } /** * Create a blank NOTE entry and hand its id to [onOpen] so the caller can * navigate straight into the full-screen notes editor (brief Fix 6 item 7) — * a new note never shows the generic edit form. */ fun createNoteAndOpen(onOpen: (String) -> Unit) { viewModelScope.launch { runCatching { vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0)) }.onSuccess { onOpen(it.id) } .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } } } } fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) } fun setUsername(v: String) = _form.update { it.copy(username = v) } fun setEmail(v: String) = _form.update { it.copy(email = v) } fun setPassword(v: String) = _form.update { it.copy(password = v) } fun setUrl(v: String) = _form.update { it.copy(url = v) } fun setAdditionalUrl(index: Int, v: String) = _form.update { form -> form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v }) } fun addAdditionalUrl() = _form.update { form -> if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form else form.copy(additionalUrls = form.additionalUrls + "") } fun removeAdditionalUrl(index: Int) = _form.update { form -> form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) }) } fun setTotp(v: String) = _form.update { it.copy(totp = v) } fun setNotes(v: String) = _form.update { it.copy(notes = v) } fun revealTotp() = _form.update { it.copy(showTotp = true) } fun revealNotes() = _form.update { it.copy(showNotes = true) } fun setCardNumber(v: String) = _form.update { val formatted = CardFieldUtils.formatPan(v) it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null) } fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) } fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) } fun setCvc(v: String) = _form.update { it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork))) } fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) } val allTags: List = vault.getDistinctTags() fun addTag(raw: String) = _form.update { f -> val tag = raw.trim().lowercase().take(32) if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f else f.copy(tags = f.tags + tag) } fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) } fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) } fun toggleGenerator() = _form.update { it.copy(generatorOpen = !it.generatorOpen, showHistory = false) } fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) } /** Restore a past password into the editor; the save flow records the swap. */ fun restorePassword(pw: String) = _form.update { it.copy(password = pw, showPassword = true, showHistory = false) } /** Copy the current generated/typed password to the clipboard with a drain latch. */ fun copyGenerated() { val pw = _form.value.password if (pw.isEmpty()) return PeachClipboard.copy(appContext, "peach", pw) viewModelScope.launch { _form.update { it.copy(genCopied = true) } kotlinx.coroutines.delay(1200) _form.update { it.copy(genCopied = false) } } } fun copyHistoryPassword(pw: String) { PeachClipboard.copy(appContext, "peach", pw) } fun setGenLength(n: Int) = _form.update { it.copy(genLength = n) } fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b) } fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b) } fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b) } fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b) } fun generate() { val f = _form.value val pw = PasswordGenerator.generate( PasswordGenerator.Options( length = f.genLength, includeUppercase = f.genUpper, includeLowercase = f.genLower, includeNumbers = f.genNumbers, includeSymbols = f.genSymbols, ), ) _form.update { it.copy(password = pw, showPassword = true) } } fun save(onDone: () -> Unit) { val f = _form.value if (f.title.isBlank()) { _form.update { it.copy(error = "title is required") } return } if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) { _form.update { it.copy(error = "Cardholder name and card number are required") } return } _form.update { it.copy(saving = true, error = null) } viewModelScope.launch { runCatching { if (isNew) { vault.addEntry( VaultEntry( id = "", type = f.type, title = f.title.trim(), username = f.username.ifBlank { null }, email = f.email.ifBlank { null }, password = f.password.ifBlank { null }, url = f.url.ifBlank { null }, additionalUrls = f.additionalUrls .map { it.trim() }.filter { it.isNotBlank() } .ifEmpty { null }, totp = f.totp.ifBlank { null }, notes = f.notes.ifBlank { null }, cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null }, cardholderName = f.cardholderName.ifBlank { null }, expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null }, expYear = f.expiry.filter { it.isDigit() }.drop(2).take(2).ifBlank { null }, cvc = f.cvc.ifBlank { null }, cardNetwork = f.cardNetwork.takeIf { it != "unknown" }, tags = f.tags.ifEmpty { null }, createdAt = 0, modifiedAt = 0, ), ) } else { vault.updateEntry( existing!!.copy( type = f.type, title = f.title.trim(), username = f.username.ifBlank { null }, email = f.email.ifBlank { null }, password = f.password.ifBlank { null }, url = f.url.ifBlank { null }, additionalUrls = f.additionalUrls .map { it.trim() }.filter { it.isNotBlank() } .ifEmpty { null }, totp = f.totp.ifBlank { null }, notes = f.notes.ifBlank { null }, cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null }, cardholderName = f.cardholderName.ifBlank { null }, expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null }, expYear = f.expiry.filter { it.isDigit() }.drop(2).take(2).ifBlank { null }, cvc = f.cvc.ifBlank { null }, cardNetwork = f.cardNetwork.takeIf { it != "unknown" }, tags = f.tags.ifEmpty { null }, ), ) } }.onSuccess { onDone() } .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } } } } }