NotesModeViewModel.kt

5.8 KB · raw

1package com.peachpasswords.ui.vault23import androidx.lifecycle.SavedStateHandle4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.vault.VaultEntry7import com.peachpasswords.vault.VaultRepository8import com.peachpasswords.vault.isNote9import dagger.hilt.android.lifecycle.HiltViewModel10import kotlinx.coroutines.Job11import kotlinx.coroutines.delay12import kotlinx.coroutines.flow.MutableStateFlow13import kotlinx.coroutines.flow.SharingStarted14import kotlinx.coroutines.flow.StateFlow15import kotlinx.coroutines.flow.asStateFlow16import kotlinx.coroutines.flow.combine17import kotlinx.coroutines.flow.map18import kotlinx.coroutines.flow.stateIn19import kotlinx.coroutines.launch20import javax.inject.Inject2122/**23 * Backs [NotesModeScreen]. Holds the live note entry, an 800ms-debounced24 * auto-save, and a transient "saved." pulse the screen fades in/out.25 *26 * Title and body are committed independently through [updateEntry] — both27 * funnel through a single debounced job so a rapid title-then-body change28 * coalesces into one write. The repository's updateEntry is a full-entry29 * replace, so each save patches the latest known entry, never a stale copy.30 */31@HiltViewModel32class NotesModeViewModel @Inject constructor(33    private val vault: VaultRepository,34    savedStateHandle: SavedStateHandle,35) : ViewModel() {36    private val id: String = savedStateHandle.get<String>("id").orEmpty()37    private val _activeNoteId = MutableStateFlow(id.takeIf { it.isNotBlank() })3839    val entry: StateFlow<VaultEntry?> =40        combine(vault.entries, _activeNoteId) { list, activeId ->41            activeId?.let { id -> list.firstOrNull { it.id == id && it.isNote() } }42        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id)?.takeIf { it.isNote() })4344    val notes: StateFlow<List<VaultEntry>> =45        vault.entries.map { list ->46            list.filter { it.isNote() }47                .sortedByDescending { it.lastUsedAt ?: it.modifiedAt }48        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntries().filter { it.isNote() })4950    val activeNoteId: StateFlow<String?> = _activeNoteId.asStateFlow()5152    private val _isMultiSelectMode = MutableStateFlow(false)53    val isMultiSelectMode: StateFlow<Boolean> = _isMultiSelectMode.asStateFlow()5455    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())56    val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()5758    private val _savedPulse = MutableStateFlow(0L)59    /** Increments on each successful auto-save so the screen can re-trigger its fade. */60    val savedPulse: StateFlow<Long> = _savedPulse.asStateFlow()6162    private var saveJob: Job? = null6364    /** Debounced note-body save. Reschedules on every keystroke; fires 800ms after the last. */65    fun onContentChanged(plainText: String) =66        scheduleSave { it.copy(notes = plainText.ifBlank { null }) }6768    /**69     * Title save. Debounced like the body so blur-after-typing still lands;70     * callers may also force an immediate save on blur via [saveTitleNow].71     */72    fun onTitleChanged(title: String) =73        scheduleSave { it.copy(title = title) }7475    /** Commit the title immediately (e.g. on field blur), bypassing the debounce. */76    fun saveTitleNow(title: String) {77        saveJob?.cancel()78        commit { it.copy(title = title) }79    }8081    fun openNote(noteId: String) {82        saveJob?.cancel()83        exitMultiSelect()84        _activeNoteId.value = noteId85    }8687    fun showToc() {88        saveJob?.cancel()89        exitMultiSelect()90        _activeNoteId.value = null91    }9293    fun createNote() {94        saveJob?.cancel()95        exitMultiSelect()96        viewModelScope.launch {97            val created = vault.addEntry(98                VaultEntry(99                    id = "",100                    type = "note",101                    title = "Untitled",102                    notes = null,103                    createdAt = 0L,104                    modifiedAt = 0L,105                ),106            )107            _activeNoteId.value = created.id108        }109    }110111    /** Browser parity: back navigation commits both title and body before leaving. */112    fun saveNow(title: String, plainText: String) {113        saveJob?.cancel()114        commit {115            it.copy(116                title = title.ifBlank { "Untitled" },117                notes = plainText.ifBlank { null },118            )119        }120    }121122    fun delete(onDone: () -> Unit) {123        viewModelScope.launch {124            _activeNoteId.value?.let { runCatching { vault.deleteEntry(it) } }125            _activeNoteId.value = null126            onDone()127        }128    }129130    fun enterMultiSelect(noteId: String) {131        _isMultiSelectMode.value = true132        _selectedIds.value = setOf(noteId)133    }134135    fun toggleSelected(noteId: String) {136        val next = _selectedIds.value.toMutableSet()137        if (!next.add(noteId)) next.remove(noteId)138        _selectedIds.value = next139    }140141    fun exitMultiSelect() {142        _isMultiSelectMode.value = false143        _selectedIds.value = emptySet()144    }145146    fun batchDelete() {147        val ids = _selectedIds.value.toList()148        if (ids.isEmpty()) return149        viewModelScope.launch {150            ids.forEach { id -> runCatching { vault.deleteEntry(id) } }151            exitMultiSelect()152        }153    }154155    fun lock() = vault.lock()156157    private fun scheduleSave(patch: (VaultEntry) -> VaultEntry) {158        saveJob?.cancel()159        saveJob = viewModelScope.launch {160            delay(800)161            commit(patch)162        }163    }164165    private fun commit(patch: (VaultEntry) -> VaultEntry) {166        val current = entry.value ?: return167        viewModelScope.launch {168            runCatching { vault.updateEntry(patch(current)) }169                .onSuccess { _savedPulse.value = System.currentTimeMillis() }170        }171    }172}