package com.peachpasswords.ui.vault import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.isNote import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay 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 /** * Backs [NotesModeScreen]. Holds the live note entry, an 800ms-debounced * auto-save, and a transient "saved." pulse the screen fades in/out. * * Title and body are committed independently through [updateEntry] — both * funnel through a single debounced job so a rapid title-then-body change * coalesces into one write. The repository's updateEntry is a full-entry * replace, so each save patches the latest known entry, never a stale copy. */ @HiltViewModel class NotesModeViewModel @Inject constructor( private val vault: VaultRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val id: String = savedStateHandle.get("id").orEmpty() private val _activeNoteId = MutableStateFlow(id.takeIf { it.isNotBlank() }) val entry: StateFlow = combine(vault.entries, _activeNoteId) { list, activeId -> activeId?.let { id -> list.firstOrNull { it.id == id && it.isNote() } } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id)?.takeIf { it.isNote() }) val notes: StateFlow> = vault.entries.map { list -> list.filter { it.isNote() } .sortedByDescending { it.lastUsedAt ?: it.modifiedAt } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntries().filter { it.isNote() }) val activeNoteId: StateFlow = _activeNoteId.asStateFlow() private val _isMultiSelectMode = MutableStateFlow(false) val isMultiSelectMode: StateFlow = _isMultiSelectMode.asStateFlow() private val _selectedIds = MutableStateFlow>(emptySet()) val selectedIds: StateFlow> = _selectedIds.asStateFlow() private val _savedPulse = MutableStateFlow(0L) /** Increments on each successful auto-save so the screen can re-trigger its fade. */ val savedPulse: StateFlow = _savedPulse.asStateFlow() private var saveJob: Job? = null private val untouchedDraftIds = mutableSetOf() /** Debounced note-body save. Reschedules on every keystroke; fires 800ms after the last. */ fun onContentChanged(plainText: String) { if (plainText.isNotBlank()) _activeNoteId.value?.let(untouchedDraftIds::remove) scheduleSave { it.copy(notes = plainText.ifBlank { null }) } } /** * Title save. Debounced like the body so blur-after-typing still lands; * callers may also force an immediate save on blur via [saveTitleNow]. */ fun onTitleChanged(title: String) { if (title.isNotBlank()) _activeNoteId.value?.let(untouchedDraftIds::remove) scheduleSave { it.copy(title = title) } } /** Commit the title immediately (e.g. on field blur), bypassing the debounce. */ fun saveTitleNow(title: String) { saveJob?.cancel() commit { it.copy(title = title) } } fun openNote(noteId: String) { saveJob?.cancel() exitMultiSelect() _activeNoteId.value = noteId } fun showToc() { saveJob?.cancel() exitMultiSelect() _activeNoteId.value = null } fun createNote() { saveJob?.cancel() exitMultiSelect() viewModelScope.launch { val created = vault.addEntry( VaultEntry( id = "", type = "note", title = "", notes = null, createdAt = 0L, modifiedAt = 0L, ), ) untouchedDraftIds += created.id _activeNoteId.value = created.id } } fun finishNote(title: String, plainText: String, onDone: () -> Unit) { saveJob?.cancel() val activeId = _activeNoteId.value if (shouldDiscardUntouchedNote(activeId in untouchedDraftIds, title, plainText)) { viewModelScope.launch { activeId?.let { runCatching { vault.deleteEntry(it) } } untouchedDraftIds.remove(activeId) onDone() } } else { saveNow(title, plainText) onDone() } } /** Browser parity: back navigation commits both title and body before leaving. */ fun saveNow(title: String, plainText: String) { saveJob?.cancel() commit { it.copy( title = title.ifBlank { "Untitled" }, notes = plainText.ifBlank { null }, ) } } fun delete(onDone: () -> Unit) { viewModelScope.launch { _activeNoteId.value?.let { runCatching { vault.deleteEntry(it) } } _activeNoteId.value = null onDone() } } fun enterMultiSelect(noteId: String) { _isMultiSelectMode.value = true _selectedIds.value = setOf(noteId) } fun toggleSelected(noteId: String) { val next = _selectedIds.value.toMutableSet() if (!next.add(noteId)) next.remove(noteId) _selectedIds.value = next } fun exitMultiSelect() { _isMultiSelectMode.value = false _selectedIds.value = emptySet() } fun batchDelete() { val ids = _selectedIds.value.toList() if (ids.isEmpty()) return viewModelScope.launch { ids.forEach { id -> runCatching { vault.deleteEntry(id) } } exitMultiSelect() } } fun lock() = vault.lock() private fun scheduleSave(patch: (VaultEntry) -> VaultEntry) { saveJob?.cancel() saveJob = viewModelScope.launch { delay(800) commit(patch) } } private fun commit(patch: (VaultEntry) -> VaultEntry) { val current = entry.value ?: return viewModelScope.launch { runCatching { vault.updateEntry(patch(current)) } .onSuccess { _savedPulse.value = System.currentTimeMillis() } } } } internal fun shouldDiscardUntouchedNote(isUntouchedDraft: Boolean, title: String, body: String): Boolean = isUntouchedDraft && title.isBlank() && body.isBlank()