NotesModeViewModel.kt

6.7 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? = null63    private val untouchedDraftIds = mutableSetOf<String>()6465    /** Debounced note-body save. Reschedules on every keystroke; fires 800ms after the last. */66    fun onContentChanged(plainText: String) {67        if (plainText.isNotBlank()) _activeNoteId.value?.let(untouchedDraftIds::remove)68        scheduleSave { it.copy(notes = plainText.ifBlank { null }) }69    }7071    /**72     * Title save. Debounced like the body so blur-after-typing still lands;73     * callers may also force an immediate save on blur via [saveTitleNow].74     */75    fun onTitleChanged(title: String) {76        if (title.isNotBlank()) _activeNoteId.value?.let(untouchedDraftIds::remove)77        scheduleSave { it.copy(title = title) }78    }7980    /** Commit the title immediately (e.g. on field blur), bypassing the debounce. */81    fun saveTitleNow(title: String) {82        saveJob?.cancel()83        commit { it.copy(title = title) }84    }8586    fun openNote(noteId: String) {87        saveJob?.cancel()88        exitMultiSelect()89        _activeNoteId.value = noteId90    }9192    fun showToc() {93        saveJob?.cancel()94        exitMultiSelect()95        _activeNoteId.value = null96    }9798    fun createNote() {99        saveJob?.cancel()100        exitMultiSelect()101        viewModelScope.launch {102            val created = vault.addEntry(103                VaultEntry(104                    id = "",105                    type = "note",106                    title = "",107                    notes = null,108                    createdAt = 0L,109                    modifiedAt = 0L,110                ),111            )112            untouchedDraftIds += created.id113            _activeNoteId.value = created.id114        }115    }116117    fun finishNote(title: String, plainText: String, onDone: () -> Unit) {118        saveJob?.cancel()119        val activeId = _activeNoteId.value120        if (shouldDiscardUntouchedNote(activeId in untouchedDraftIds, title, plainText)) {121            viewModelScope.launch {122                activeId?.let { runCatching { vault.deleteEntry(it) } }123                untouchedDraftIds.remove(activeId)124                onDone()125            }126        } else {127            saveNow(title, plainText)128            onDone()129        }130    }131132    /** Browser parity: back navigation commits both title and body before leaving. */133    fun saveNow(title: String, plainText: String) {134        saveJob?.cancel()135        commit {136            it.copy(137                title = title.ifBlank { "Untitled" },138                notes = plainText.ifBlank { null },139            )140        }141    }142143    fun delete(onDone: () -> Unit) {144        viewModelScope.launch {145            _activeNoteId.value?.let { runCatching { vault.deleteEntry(it) } }146            _activeNoteId.value = null147            onDone()148        }149    }150151    fun enterMultiSelect(noteId: String) {152        _isMultiSelectMode.value = true153        _selectedIds.value = setOf(noteId)154    }155156    fun toggleSelected(noteId: String) {157        val next = _selectedIds.value.toMutableSet()158        if (!next.add(noteId)) next.remove(noteId)159        _selectedIds.value = next160    }161162    fun exitMultiSelect() {163        _isMultiSelectMode.value = false164        _selectedIds.value = emptySet()165    }166167    fun batchDelete() {168        val ids = _selectedIds.value.toList()169        if (ids.isEmpty()) return170        viewModelScope.launch {171            ids.forEach { id -> runCatching { vault.deleteEntry(id) } }172            exitMultiSelect()173        }174    }175176    fun lock() = vault.lock()177178    private fun scheduleSave(patch: (VaultEntry) -> VaultEntry) {179        saveJob?.cancel()180        saveJob = viewModelScope.launch {181            delay(800)182            commit(patch)183        }184    }185186    private fun commit(patch: (VaultEntry) -> VaultEntry) {187        val current = entry.value ?: return188        viewModelScope.launch {189            runCatching { vault.updateEntry(patch(current)) }190                .onSuccess { _savedPulse.value = System.currentTimeMillis() }191        }192    }193}194195internal fun shouldDiscardUntouchedNote(isUntouchedDraft: Boolean, title: String, body: String): Boolean =196    isUntouchedDraft && title.isBlank() && body.isBlank()