feat: cliente Android offline-first (Kotlin + Jetpack Compose)

- Room (notes/outbox/categories/meta): edição 100% local, fila FIFO de mutações
- SyncEngine bidirecional: push (create/update com base_hash/delete, rekey de id
  local→servidor) + pull (categorias, notas divergentes, exclusões remotas)
- Conflito 409: servidor vence a nota + texto local preservado em cópia
  "Título (conflito)" enfileirada como create — nada perdido, sem relógios
- WorkManager: periódico 1h (rede + backoff exponencial), runNow único REPLACE
  em gatilhos (pós-save, rede restabelecida, boot, config, botão manual)
- OkHttp com timeout/retry em IOException; kotlinx.serialization; DataStore
- UI Compose: tela Servidor (URL+token+teste), Lista com banner de status
  (Offline/Sincronizando/Sincronizado/Erro/401 + pendências + conflitos),
  Editor com autosave por ociosidade (10 s) e categorias multi-seleção
- Build validado: assembleDebug (AGP 8.7.3/Kotlin 2.0.21/SDK 35, JDK 21) —
  app-debug.apk 17,6 MB; gradle-wrapper.jar fora do commit (regenerável com
  `gradle wrapper`, documentado no README)
This commit is contained in:
lgcosta committed 2026-11-04 17:55:50 -03:00
commit 2f63c7a2f5
30 files changed
+2096

No files matched your search

@@ -0,0 +1,136 @@
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 java.util.UUID
import kotlinx.coroutines.Dispatchers
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
sealed class ListEvent {
data class ConnectionOk(val notes: Int) : ListEvent()
data class ConnectionFail(val message: 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
fun saveSettings(s: Settings) = viewModelScope.launch {
settingsRepo.save(s)
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,
),
)
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
}
return id
}
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
fun saveNote(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) = viewModelScope.launch(Dispatchers.IO) {
val existing = db.notes().byId(id) ?: return@launch
val now = System.currentTimeMillis()
val contentChanged = existing.content != content
db.notes().upsert(
existing.copy(
title = title,
format = format,
categories = categories.joinToString(","),
content = content,
updatedAt = now,
dirty = true,
conflictNote = "",
),
)
if (contentChanged && !db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "update", queuedAt = now))
SyncScheduler.runNow(getApplication(), "autosave")
}
}
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
val note = db.notes().byId(id) ?: return@launch
if (note.isLocalOnly) {
db.notes().delete(id)
db.outbox().dropFor(id)
} else {
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = id, op = "delete", queuedAt = System.currentTimeMillis()))
SyncScheduler.runNow(getApplication(), "delete")
}
}
suspend fun noteById(id: String): NoteEntity? = withContext(Dispatchers.IO) {
db.notes().byId(id)
}
}