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