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:
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))
|
||||
|
||||
Reference in new issue
Block a user