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

@@ -9,7 +9,7 @@ import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.data.remote.NoteDetailDto
import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant
import com.labexposed.notas.data.remote.toNoteEntity
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
@@ -37,6 +37,7 @@ class SyncEngine(
private val db: NotasDatabase,
private val settings: SettingsRepository,
private val status: SyncStatusStore,
private val session: String = "android",
) {
private suspend fun api(): ApiClient? {
@@ -106,6 +107,21 @@ class SyncEngine(
// ------------------------------------------------------------------ push
private suspend fun push(client: ApiClient, author: String): SyncOutcome {
// auto-reconciliação: notas sujas sem entrada na fila (ex.: só mudaram
// metadados, ou ficaram presas de versões anteriores do app)
for (n in db.notes().all()) {
if (n.dirty && !n.pendingDelete &&
!db.outbox().has(n.id, "create") && !db.outbox().has(n.id, "update")
) {
db.outbox().enqueue(
OutboxEntity(
noteId = n.id,
op = if (n.isLocalOnly) "create" else "update",
queuedAt = System.currentTimeMillis(),
),
)
}
}
val queue = db.outbox().queue()
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
var pushed = 0
@@ -152,6 +168,12 @@ class SyncEngine(
),
)
db.outbox().drop(entry.seq)
if (!clean && !fresh.pendingDelete) {
// conteúdo mudou durante o push: mantém na fila
db.outbox().enqueue(
OutboxEntity(noteId = fresh.id, op = "update", queuedAt = System.currentTimeMillis()),
)
}
pushed++
}
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
@@ -316,23 +338,10 @@ class SyncEngine(
author = author,
message = "sync android",
baseHash = serverHash,
session = this@SyncEngine.session,
)
private fun NoteDetailDto.toEntity() = NoteEntity(
id = id,
title = title,
format = format,
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).joinToString(","),
content = content,
serverHash = hash,
createdAt = parseMillis(createdAt),
updatedAt = parseMillis(updatedAt),
dirty = false,
)
private fun parseMillis(iso: String): Long =
runCatching { Instant.parse(iso).toEpochMilli() }.getOrElse { System.currentTimeMillis() }
private fun NoteDetailDto.toEntity() = toNoteEntity()
private fun describe(r: ApiResult<*>): String = when (r) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"