package com.peachpasswords.ui.vault import android.content.Context import com.peachpasswords.shared.PeachClipboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.license.PendingProNotice import com.peachpasswords.license.ProNoticeStore import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultLockTimer import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.isLogin import com.peachpasswords.vault.isNote import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject enum class SortOrder(val label: String) { SMART("SMART"), AZ("A–Z"), ZA("Z–A"), NEWEST("NEWEST") } /** When the NOTES filter pill is active, keep only NOTE-type entries. */ internal fun filterNotesOnly(list: List, noteOnly: Boolean): List = if (noteOnly) list.filter { it.isNote() } else list @HiltViewModel class VaultListViewModel @Inject constructor( private val vault: VaultRepository, private val proNoticeStore: ProNoticeStore, @ApplicationContext private val appContext: Context, private val lockTimer: VaultLockTimer, ) : ViewModel() { private val _sort = MutableStateFlow(SortOrder.SMART) val sort: StateFlow = _sort.asStateFlow() private val _search = MutableStateFlow("") val search: StateFlow = _search.asStateFlow() private val _tagFilter = MutableStateFlow(null) val activeTagFilter: StateFlow = _tagFilter.asStateFlow() private val _noteOnly = MutableStateFlow(false) val noteOnly: StateFlow = _noteOnly.asStateFlow() private val _flashId = MutableStateFlow(null) val flashId: StateFlow = _flashId.asStateFlow() val isUnlocked: StateFlow = vault.isUnlocked val proNotice: StateFlow = proNoticeStore.notice /** Live, filtered + sorted active entries. */ val entries: StateFlow> = combine(vault.entries, _sort, _search, _tagFilter, _noteOnly) { list, sort, query, tag, noteOnly -> val tagged = if (tag == null) list else list.filter { e -> e.tags?.any { it.equals(tag, ignoreCase = true) } == true } applyFilterSort(filterNotesOnly(tagged, noteOnly), sort, query) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val allTags: StateFlow> = vault.entries.map { vault.getDistinctTags() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val totalCount: StateFlow = vault.entries.map { it.size } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) fun setSort(order: SortOrder) { _sort.value = order } fun setSearch(q: String) { _search.value = q } fun setTagFilter(tag: String?) { _tagFilter.value = tag } fun toggleNoteFilter() { _noteOnly.value = !_noteOnly.value } /** Lock the vault from the list's lock button; the nav host drops to Lock. */ fun lock() { vault.lock() lockTimer.cancel() } fun dismissProNotice() { proNoticeStore.dismiss() } /** Create a blank note and invoke [onOpen] with its id for navigation. */ fun createNote(onOpen: (String) -> Unit) { viewModelScope.launch { val created = vault.addEntry( VaultEntry( id = "", type = "note", title = "Untitled", notes = null, createdAt = 0L, modifiedAt = 0L, ), ) onOpen(created.id) } } // ── Multi-select ──────────────────────────────────────────────────────── private val _isMultiSelectMode = MutableStateFlow(false) val isMultiSelectMode: StateFlow = _isMultiSelectMode.asStateFlow() private val _selectedIds = MutableStateFlow>(emptySet()) val selectedIds: StateFlow> = _selectedIds.asStateFlow() fun enterMultiSelect(id: String) { _isMultiSelectMode.value = true _selectedIds.value = setOf(id) } fun toggleSelected(id: String) { val next = _selectedIds.value.toMutableSet() if (!next.add(id)) next.remove(id) _selectedIds.value = next } fun exitMultiSelect() { _isMultiSelectMode.value = false _selectedIds.value = emptySet() } // ── Soft delete + undo ────────────────────────────────────────────────── /** One-shot undo event for the snackbar: label + ids to restore. */ data class UndoEvent(val message: String, val ids: List) private val _undoEvent = MutableStateFlow(null) val undoEvent: StateFlow = _undoEvent.asStateFlow() fun consumeUndoEvent() { _undoEvent.value = null } fun softDelete(entry: VaultEntry) { viewModelScope.launch { runCatching { vault.deleteEntry(entry.id) } _undoEvent.value = UndoEvent("${entry.title} moved to trash", listOf(entry.id)) } } fun batchSoftDelete() { val ids = _selectedIds.value.toList() if (ids.isEmpty()) return viewModelScope.launch { ids.forEach { id -> runCatching { vault.deleteEntry(id) } } exitMultiSelect() _undoEvent.value = UndoEvent("${ids.size} entries moved to trash", ids) } } fun restoreEntries(ids: List) { viewModelScope.launch { ids.forEach { id -> runCatching { vault.restoreEntry(id) } } } } // ── Context-menu copy helpers ─────────────────────────────────────────── private fun copyText(value: String?) { val v = value?.takeIf { it.isNotEmpty() } ?: return PeachClipboard.copy(appContext, "peach", v) } fun copyUsername(entry: VaultEntry) = copyText(entry.username ?: entry.email) /** Copy the LIVE TOTP code, never the stored seed. */ fun copyTotp(entry: VaultEntry) { val seed = entry.totp ?: return copyText(TOTPGenerator.generateCode(seed)) } /** Copy a login's password, flash the row, and fire the swipe-copy haptic. */ fun copyPassword(entry: VaultEntry) { val pw = entry.password ?: return PeachClipboard.copy(appContext, "peach", pw) viewModelScope.launch { _flashId.value = entry.id kotlinx.coroutines.delay(600) if (_flashId.value == entry.id) _flashId.value = null } } private fun applyFilterSort(list: List, sort: SortOrder, query: String): List = filterAndSortVaultEntries(list, sort, query) fun canSwipeCopy(entry: VaultEntry): Boolean = entry.isLogin() && !entry.password.isNullOrEmpty() } internal fun filterAndSortVaultEntries( list: List, sort: SortOrder, query: String, ): List { val q = query.trim() if (q.isNotEmpty()) { return list .filter { fuzzyMatch(q, vaultEntrySearchText(it)) } .sortedWith(searchHitComparator(q)) } return when (sort) { SortOrder.AZ -> list.sortedWith(compareByTitleId()) SortOrder.ZA -> list.sortedWith(compareByDescending { it.title.lowercase() }.thenBy { it.id }) SortOrder.NEWEST, SortOrder.SMART -> list.sortedWith(compareByRecency()) } } internal fun vaultEntrySearchText(entry: VaultEntry): String { if (entry.isNote()) return listOf(entry.title, notePlainText(entry.notes.orEmpty())).joinToString(" ") val passkey = entry.passkey return listOfNotNull( entry.title, entry.url, entry.additionalUrls?.joinToString(" "), entry.email, entry.username, passkey?.rpId ?: entry.rpId, passkey?.rpName ?: entry.rpName, passkey?.userName ?: entry.userName, passkey?.userDisplayName ?: entry.userDisplayName, ).joinToString(" ") } internal fun vaultEntryLastUsedAt(entry: VaultEntry): Long = entry.lastUsedAt ?: entry.modifiedAt internal fun fuzzyMatch(query: String, target: String): Boolean { val q = query.lowercase() val t = target.lowercase() var qi = 0 for (ch in t) { if (qi < q.length && ch == q[qi]) qi += 1 if (qi == q.length) return true } return q.isEmpty() } private fun isExactPrefix(query: String, target: String): Boolean = target.lowercase().startsWith(query.lowercase()) private fun compareByTitleId(): Comparator = compareBy { it.title.lowercase() }.thenBy { it.id } private fun compareByRecency(): Comparator = compareByDescending { vaultEntryLastUsedAt(it) }.then(compareByTitleId()) private fun searchHitComparator(query: String): Comparator = Comparator { a, b -> val aExact = isExactPrefix(query, vaultEntrySearchText(a)) val bExact = isExactPrefix(query, vaultEntrySearchText(b)) when { aExact && !bExact -> -1 !aExact && bExact -> 1 else -> compareByRecency().compare(a, b) } }