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