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 ✅
This commit is contained in:
lgcosta committed 2026-11-04 20:52:59 -03:00
1 parent 9e0bcc3e9b
commit 61d4abebbd
12 files changed
+1096 -154

No files matched your search

@@ -13,8 +13,10 @@ 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
@@ -22,10 +24,14 @@ 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) {
@@ -54,8 +60,104 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
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")
}
@@ -100,7 +202,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
return if (db.notes().byId(mapped) != null) mapped else null
}
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
/** Salva edição local (conteúdo OU metadados) + enfileira + agenda sync. */
suspend fun saveNoteSync(
id: String,
title: String,
@@ -112,6 +214,8 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
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,
@@ -124,7 +228,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
conflictNote = "",
),
)
if (contentChanged) {
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))