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,280 @@
package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner
import com.labexposed.notas.domain.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val AUTOSAVE_IDLE_MS = 10_000L
/** Tela de configuração: URL do servidor + token + autor, com teste de conexão. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.serverUrl) }
var token by remember { mutableStateOf(settings.token) }
var author by remember { mutableStateOf(settings.author) }
var feedback by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val events by vm.events.collectAsState()
LaunchedEffect(events) {
when (val e = events) {
is ListEvent.ConnectionOk -> feedback = "OK! ${e.notes} nota(s) visíveis no servidor."
is ListEvent.ConnectionFail -> feedback = "Falhou: ${e.message}"
null -> {}
}
if (events != null) vm.consumeEvent()
}
Scaffold(topBar = { TopAppBar(title = { Text("Servidor") }) }) { pad ->
Column(
modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url, onValueChange = { url = it },
label = { Text("URL da API (ex.: http://192.168.0.10:8080)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token, onValueChange = { token = it },
label = { Text("Token de acesso (NOTES_TOKEN)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = author, onValueChange = { author = it },
label = { Text("Autor das revisões") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
if (feedback.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(feedback, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { scope.launch { vm.testConnection(Settings(url, token, author)) } }) {
Text("Testar conexão")
}
TextButton(
enabled = url.isNotBlank() && token.isNotBlank(),
onClick = {
vm.saveSettings(Settings(url, token, author))
onDone()
},
) { Text("Salvar e continuar") }
}
}
}
}
/** Tela principal: lista local de notas + banner de sincronização. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesListScreen(
vm: NotasViewModel,
onOpen: (String) -> Unit,
onNew: () -> Unit,
onSettings: () -> Unit,
) {
val notes by vm.notes.collectAsState()
val sync by vm.syncState.collectAsState()
val cats by vm.categories.collectAsState()
val catNames = remember(cats) { cats.associate { it.id to it.name } }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notas") },
actions = {
TextButton(onClick = { vm.syncNow() }) { Text("Sincronizar") }
TextButton(onClick = onSettings) { Text("Servidor") }
},
)
},
floatingActionButton = {
androidx.compose.material3.FloatingActionButton(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = "Nova nota")
}
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
SyncBanner(sync)
if (notes.isEmpty()) {
Text(
"Nenhuma nota local.\nToque em + para criar (funciona offline).",
modifier = Modifier.padding(24.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp),
) {
items(notes, key = { it.id }) { note ->
androidx.compose.material3.Card(
onClick = { onOpen(note.id) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(
(note.title.ifBlank { "Sem título" }) +
when (note.format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" },
style = MaterialTheme.typography.titleSmall,
)
val bits = mutableListOf(
java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(note.updatedAt)),
)
note.categories.split(",").filter { it.isNotBlank() }
.map { catNames[it] ?: it }.ifNotEmpty()?.let { bits.add(it.joinToString(" · ")) }
if (note.tags.isNotBlank()) bits.add(note.tags.split(",").joinToString(" ") { "#$it" })
Text(bits.joinToString(" · "), style = MaterialTheme.typography.bodySmall)
if (note.conflictNote.isNotBlank()) {
Text("⚠ ${note.conflictNote}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error)
}
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
if (note.dirty) Text("● não enviado", color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.labelSmall)
if (note.pendingDelete) Text("🗑 fila", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
}
private fun <T> List<T>.ifNotEmpty(): List<T>? = if (isEmpty()) null else this
/** Editor offline-first: autosave local após 10 s de ociosidade + salvar explícito. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
var loaded by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var format by remember { mutableStateOf("md") }
var content by remember { mutableStateOf("") }
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
var savedAt by remember { mutableStateOf("") }
val cats by vm.categories.collectAsState()
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(noteId) {
vm.noteById(noteId)?.let {
title = it.title
format = it.format
content = it.content
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
}
loaded = true
}
fun persist() {
if (!loaded) return
vm.saveNote(noteId, title, format, selectedCats, content)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date())
}
// ociosidade: o efeito é reiniciado a cada alteração; só grava se
// passarem 10 s sem nenhuma (cada tecla cancela o delay anterior)
LaunchedEffect(title, format, content, loaded) {
if (!loaded) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
persist()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title.ifBlank { "Nova nota" }) },
navigationIcon = { TextButton(onClick = { persist(); onBack() }) { Text("←") } },
actions = {
Text(savedAt.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall)
TextButton(onClick = { persist(); keyboard?.hide() }) { Text("Salvar") }
},
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it },
label = { Text("Título") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase()) })
}
}
if (cats.isNotEmpty()) {
Text("Categorias", style = MaterialTheme.typography.labelMedium)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
cats.forEach { c ->
FilterChip(
selected = selectedCats.contains(c.id),
onClick = {
selectedCats = if (selectedCats.contains(c.id))
selectedCats - c.id else selectedCats + c.id
},
label = { Text(c.name) },
)
}
}
}
OutlinedTextField(
value = content, onValueChange = { content = it },
modifier = Modifier.fillMaxWidth().weight(1f),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace),
placeholder = { Text("Escreva… #tags viram tags no servidor") },
)
}
}
}