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,98 @@
package com.labexposed.notas
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.ui.EditorScreen
import com.labexposed.notas.ui.NotesListScreen
import com.labexposed.notas.ui.NotasViewModel
import com.labexposed.notas.ui.SettingsScreen
import com.labexposed.notas.ui.theme.NotasTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NotasTheme { NotasApp_() }
}
}
}
@Composable
private fun NotasApp_(vm: NotasViewModel = viewModel()) {
val nav = rememberNavController()
val settings by vm.settings.collectAsState()
NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") {
composable("settings") {
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
}
composable("list") {
NotesListScreen(
vm = vm,
onOpen = { nav.navigate("editor/$it") },
onNew = { nav.navigate("editor/" + vm.createLocal()) },
onSettings = { nav.navigate("settings") },
)
}
composable("editor/{id}") { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
EditorScreen(vm = vm, noteId = id, onBack = { nav.popBackStack() })
}
}
}
/** Banner de status: Offline · Sincronizando · Sincronizado · Erro · pendências. */
@Composable
fun SyncBanner(state: SyncUiState, modifier: Modifier = Modifier) {
val (bg, fg, label) = when (state.phase) {
SyncPhase.NotConfigured -> Quinteto(Color(0xFF9A9A9A), Color.White, "Configure o servidor ⚙")
SyncPhase.Offline -> Quinteto(Color(0xFF6F6F6F), Color.White, "Offline — ${state.pendingCount} alteração(ões) na fila")
SyncPhase.Syncing -> Quinteto(Color(0xFF3584E4), Color.White, "Sincronizando…")
SyncPhase.Synced -> {
val pend = if (state.pendingCount > 0) " · ${state.pendingCount} pendente(s)" else ""
val conf = if (state.conflicts > 0) " · ⚠ ${state.conflicts} conflito(s)" else ""
val hora = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(state.lastSyncAt))
Quinteto(Color(0xFF2EC27E), Color(0xFF073042), "Sincronizado às $hora$pend$conf")
}
SyncPhase.Error -> Quinteto(Color(0xFFE01B24), Color.White, "Erro: ${state.message}")
SyncPhase.Unauthorized -> Quinteto(Color(0xFFE01B24), Color.White, "Token inválido — revise em Servidor")
}
Surface(color = bg, shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = fg, style = MaterialTheme.typography.labelLarge)
if (state.dirtyCount > 0 && state.phase == SyncPhase.Synced) {
Text("● ${state.dirtyCount} não enviado(s)", color = fg, style = MaterialTheme.typography.labelMedium)
}
}
}
}
private data class Quinteto(val bg: Color, val fg: Color, val label: String)