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