Files
notas-online-android/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt
T
lgcosta 61d4abebbd feat: v0.2.0 — sincronização do botão garantida, SSE em tempo real, WYSIWYG/Preview e lista rica
Sync (bug do celular real):
- mudanças só de metadados (título/formato/categorias) entram na fila
- auto-reconciliação: toda rodada enfileira notas sujas sem entrada na fila
- re-enfileira update se o usuário digitou durante o push
- sync no ON_RESUME; botão Sincronizar com spinner no lugar do ícone

Tempo real (SSE):
- SseClient (OkHttp, Dispatchers.IO, backoff) consome /api/events com Bearer
- saved: nota limpa recarrega sozinha (editor aberto incluído, com rekey por meta),
  nota suja mostra banner Recarregar/Manter; snackbar na lista
- presence: 👥N na lista/editor quando outra sessão (web ou app) abre a mesma nota
- SessionId único por instalação, enviado em presence e ecoado nos saves

Editor moderno (WebView nativo, sem deps novas):
- modos Editar / WYSIWYG / Preview; WYSIWYG = contenteditable com serializador
  html→md em JS na página-asset + ponte @JavascriptInterface (tudo na main thread)
- toolbar Markdown no modo texto (wrap/prefix seleção) e execCommand no WYSIWYG
- Preview renderiza md via /api/render; links não navegam fora do app
- lista: MediumTopAppBar, chips de filtro (categoria com caminho da árvore e tags),
  cards com status ☁/●/🗑/ e 👥, FAB estendido; telas em Material 3 dynamic color

Testado no emulador Android 15: título-only sync ✅, SSE ao vivo ✅, WYSIWYG→servidor ✅,
👥 presença ✅, conflito 409 com cópia única ✅
2026-11-04 20:52:59 -03:00

267 lines
11 KiB
Kotlin

package com.labexposed.notas.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.data.local.OutboxEntity
import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.domain.Settings
import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.sync.SyncScheduler
import android.content.Context
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.labexposed.notas.data.remote.SseClient
import com.labexposed.notas.data.remote.SseEvent
import com.labexposed.notas.data.remote.toNoteEntity
sealed class ListEvent {
data class ConnectionOk(val notes: Int) : ListEvent()
data class ConnectionFail(val message: String) : ListEvent()
data class RemoteChange(val title: String) : ListEvent()
}
class NotasViewModel(app: Application) : AndroidViewModel(app) {
private val db = NotasDatabase.get(app)
private val settingsRepo = SettingsRepository(app)
val settings: StateFlow<Settings> = settingsRepo.flow
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
val notes: StateFlow<List<NoteEntity>> = db.notes().observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val categories: StateFlow<List<com.labexposed.notas.data.local.CategoryEntity>> =
db.categories().observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val syncState: StateFlow<SyncUiState> = combine(
SyncStatusStore.state,
db.outbox().observeCount(),
db.notes().observePending(),
) { state, outbox, dirty ->
state.copy(pendingCount = outbox, dirtyCount = dirty)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SyncUiState.Initial)
private val _events = MutableStateFlow<ListEvent?>(null)
val events: StateFlow<ListEvent?> = _events
/** id de sessão estável por instalação (mesmo valor usado no presence/SSE). */
val sid: String = com.labexposed.notas.domain.SessionId.get(getApplication<android.app.Application>())
private var sseJob: Job? = null
private var sseApi: ApiClient? = null
/** noteId → nº de outras sessões com a nota aberta (presença). */
val presence = MutableStateFlow<Map<String, Int>>(emptyMap())
/** notas sujas que mudaram remotamente (banner no editor). */
val remoteChanged = MutableStateFlow<Set<String>>(emptySet())
/** id da nota atualmente aberta no editor (para refresh ao vivo via SSE). */
@Volatile
var openNoteId: String? = null
/** conteúdo novo para o editor aberto (consumido e limpo pela UI). */
val editorRefresh = MutableStateFlow<Pair<String, String>?>(null)
fun onResumed() {
viewModelScope.launch {
val s = settingsRepo.current()
if (!s.configured) return@launch
if (sseJob?.isActive != true) {
val client = SseClient(s.serverUrl, s.token, sid)
sseApi = ApiClient(s.serverUrl, s.token)
sseJob = client.connect(viewModelScope) { ev -> handleSse(ev) }
}
SyncScheduler.runNow(getApplication(), "resume")
}
}
fun onPaused() {
sseJob?.cancel()
sseJob = null
}
private fun handleSse(ev: SseEvent) = viewModelScope.launch(Dispatchers.IO) {
when (ev) {
is SseEvent.Presence -> {
val map = mutableMapOf<String, Int>()
for (s in ev.sessions) {
if (s.session == sid) continue
for (n in s.notes ?: emptyList()) map[n] = (map[n] ?: 0) + 1
}
presence.value = map
}
is SseEvent.Saved -> {
if (ev.session == sid) return@launch
val note = db.notes().byId(ev.noteId) ?: return@launch
if (ev.deleted) {
if (!note.dirty) {
db.notes().delete(ev.noteId)
db.outbox().dropFor(ev.noteId)
_events.value = ListEvent.RemoteChange(ev.title)
}
return@launch
}
if (note.serverHash == ev.hash) return@launch
if (!note.dirty) {
val detail = sseApi?.getNote(ev.noteId)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toNoteEntity())
_events.value = ListEvent.RemoteChange(ev.title)
if (openNoteId == ev.noteId || db.meta().get("rekey:$openNoteId") == ev.noteId) {
openNoteId?.let { editorRefresh.value = it to detail.value.content }
}
}
} else {
remoteChanged.value = remoteChanged.value + ev.noteId
}
}
}
}
/** Nota abriu e mudou remotamente: recarrega do servidor sob demanda. */
fun reloadFromServer(id: String, then: ((NoteEntity?) -> Unit)? = null) = viewModelScope.launch(Dispatchers.IO) {
val detail = sseApi?.getNote(id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toNoteEntity())
remoteChanged.value = remoteChanged.value - id
}
then?.invoke(db.notes().byId(id))
}
fun dismissRemoteChange(id: String) {
remoteChanged.value = remoteChanged.value - id
}
fun renderMarkdown(content: String, onResult: (String) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
val html = sseApi?.renderMd(content)
if (html is ApiResult.Ok) onResult(html.value) else onResult("<p>…sem conexão para renderizar…</p>")
}
fun saveSettings(s: Settings) = viewModelScope.launch {
settingsRepo.save(s)
sseApi = if (s.configured) ApiClient(s.serverUrl, s.token) else null
onPaused(); onResumed()
SyncScheduler.runNow(getApplication(), "config")
}
fun testConnection(s: Settings) = viewModelScope.launch(Dispatchers.IO) {
val result = ApiClient(s.serverUrl, s.token).testConnection()
_events.value = when (result) {
is ApiResult.Ok -> ListEvent.ConnectionOk(result.value.size)
is ApiResult.Unauthorized -> ListEvent.ConnectionFail("token inválido (401)")
is ApiResult.Network -> ListEvent.ConnectionFail("sem conexão: ${result.message}")
else -> ListEvent.ConnectionFail(result.toString())
}
}
fun consumeEvent() { _events.value = null }
fun syncNow() {
SyncScheduler.runNow(getApplication(), "manual")
}
fun createLocal(): String {
val id = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
viewModelScope.launch(Dispatchers.IO) {
val now = System.currentTimeMillis()
db.notes().upsert(
NoteEntity(
id = id, title = "", format = "md", content = "",
createdAt = now, updatedAt = now, dirty = true,
),
)
// exatamente um create na fila; autosaves seguintes não re-enfileiram
if (!db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
}
}
return id
}
/** Nota por id, seguindo rekeys de create (id local → id do servidor). */
private suspend fun resolve(id: String): String? {
if (db.notes().byId(id) != null) return id
val mapped = db.meta().get("rekey:$id") ?: return null
return if (db.notes().byId(mapped) != null) mapped else null
}
/** Salva edição local (conteúdo OU metadados) + enfileira + agenda sync. */
suspend fun saveNoteSync(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) {
val targetId = resolve(id) ?: return
val existing = db.notes().byId(targetId) ?: return
val now = System.currentTimeMillis()
val contentChanged = existing.content != content
val metaChanged = existing.title != title || existing.format != format ||
existing.categories != categories.joinToString(",")
db.notes().upsert(
existing.copy(
id = targetId,
title = title,
format = format,
categories = categories.joinToString(","),
content = content,
updatedAt = now,
dirty = true,
conflictNote = "",
),
)
if (contentChanged || metaChanged) {
// nota em create pendente já está na fila com o conteúdo atual (upsert acima)
if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) {
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
}
SyncScheduler.runNow(getApplication(), "autosave")
}
}
fun saveNote(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) = viewModelScope.launch(Dispatchers.IO) {
saveNoteSync(id, title, format, categories, content)
}
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
val targetId = resolve(id) ?: return@launch
val note = db.notes().byId(targetId) ?: return@launch
if (note.isLocalOnly) {
db.notes().delete(targetId)
db.outbox().dropFor(targetId)
} else {
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "delete", queuedAt = System.currentTimeMillis()))
SyncScheduler.runNow(getApplication(), "delete")
}
}
suspend fun noteById(id: String): NoteEntity? = withContext(Dispatchers.IO) {
db.notes().byId(id)
}
}