💜 P19: Banco de Wakanda (Arquitetura Caching Offline-First)
Bem-vindo à Fase 10: Robustez Técnica & Tempo Real! 💜
Neste projeto arquitetural avançado de grande importância comercial, você aprenderá a implementar o padrão corporativo Offline-First. Diferente de apps comuns que renderizam chamadas de rede diretamente (deixando o app inutilizável se o usuário entrar em modo avião ou metrô), estruturaremos um fluxo onde a tela de interface consome estritamente do banco de dados local Room DB (que atua como a única fonte de verdade - Single Source of Truth), enquanto chamadas assíncronas de rede atualizam o cache local em segundo plano.
O tema do app será o catálogo de minérios de Vibranium e defesas tecnológicas de Wakanda.
✅ Pré-requisitos
Antes de começar, certifique-se de já ter estudado:
- 🎓 Trilha Essencial completa: Projetos P01-P10 (Cap 01-22)
- 🏗️ Projeto anterior: P18: Radar de Habilidades
- 📘 Cap 15: Meus Jogos Favoritos (Room DB)
🎯 Objetivo do Projeto
Estruturar o pipeline arquitetural de dados de forma resiliente contendo:
- Banco de Dados Caching: Declarar tabelas e persistências locais com Room preparadas para substituição de registros.
- Fluxo Único de Verdade: Garantir que as Views Compose assinem Flows estritamente vindos do DAO do banco de dados local.
- Sincronizador Noturno: Criar rotinas em repositórios que executam chamadas de rede mockadas (APIs Retrofit) e escrevem os resultados diretamente no Room.
- UI Resiliente: Garantir que o app abra imediatamente com dados em cache local na ausência total de sinal de internet.
graph TD
A[Usuário abre WakandaApp] --> B[UI assina localItems Flow do VibraniumRepository]
B --> C[VibraniumDao.getAllItems busca registros de cache local imediatamente]
C --> D[Interface renderiza o catálogo instantaneamente - 0s de espera]
D --> E[Usuário clica em Puxar da API / Sincronizar]
E --> F[VibraniumRepository dispara refreshDatabase em background]
F --> G[Retrofit API simula fetch de itens online]
G --> H[VibraniumDao.insertAll salva novos registros no Room]
H -->|Modifica banco| C
📖 Dicionário do Projeto
- Offline-First: Filosofia de design de software móvel em que o aplicativo é construído para funcionar de forma confiável na ausência total de internet, tratando conexões online como um aprimoramento em background.
- Single Source of Truth (SSOT): Prática arquitetural de designar uma única estrutura de dados (no Android, o banco local Room) como a fonte de dados soberana para a interface, evitando dados inconsistentes ou duplicados na tela.
- OnConflictStrategy.REPLACE: Regra do Room que substitui automaticamente registros locais por novas atualizações vindas do servidor caso possuam a mesma Chave Primária (
PrimaryKey). - inMemoryDatabaseBuilder: Método de criação rápida do Room que armazena os dados em memória temporária (RAM) ao invés do disco, excelente para testes e laboratórios de conferência rápidos.
🛠️ Passo 1: Estruturando a Camada de Repositório (VibraniumRepository.kt)
Diferente dos projetos anteriores, o repositório assume o papel de gerenciar o fluxo de caching e escrita.
- Crie a classe
VibraniumRepository.ktno seu pacote principal: ```kotlin package br.com.curso.wakanda
import kotlinx.coroutines.flow.Flow
class VibraniumRepository(private val dao: VibraniumDao) {
// 1. A UI do Compose escuta apenas o fluxo do banco local
val localItems: Flow<List
// 2. Chamadas de rede nunca atualizam a UI direto: elas escrevem no Room
suspend fun refreshDatabase() {
val networkData = WakandaApi.fetchNetworkItems()
dao.insertAll(networkData) // O Room notifica automaticamente o Flow reativo
} } ```
🛠️ Passo 2: Codificando a Escuta reativa do Flow no Compose
Na sua MainActivity.kt, inicialize seu banco e repositório. Use o método collectAsState para assinar o Flow local e reagir de forma transparente a qualquer inserção silenciosa em background:
val itemsListState = repository.localItems.collectAsState(initial = emptyList())
if (itemsListState.value.isEmpty()) {
Text("Nenhum item em cache local. Fique online para baixar.")
} else {
LazyColumn {
items(itemsListState.value) { item ->
// Renderiza o item carregado do Room
}
}
}
🏆 Desafios para você (Upgrade!)
Se você terminou de programar a funcionalidade base e tudo está funcionando, experimente estes upgrades:
- Persistência Real: troque
inMemoryDatabaseBuilderpordatabaseBuilder, gravando o banco em um arquivo no armazenamento do dispositivo. - Busca por Pureza: adicione um
@QuerycomWHERE purity >= :mine um campo na UI para filtrar os recursos pela pureza mínima.
📖 Gabarito Oficial de Código (Para Conferência)
Use os códigos Kotlin completos abaixo para validar sua implementação.
📄 Código de Lógica e Tela Completa (MainActivity.kt)
Disponível em: app/src/main/java/br/com/curso/wakanda/MainActivity.kt
package br.com.curso.wakanda
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WakandaTheme {
WakandaScreen()
}
}
}
}
@Composable
fun WakandaTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFFA770EF), // Roxo Wakanda (Vibranium)
background = Color(0xFF07050A),
surface = Color(0xFF130F1A)
),
content = content
)
}
// 1. ROOM ENTITY
@Entity(tableName = "vibranium_items")
data class VibraniumItem(
@PrimaryKey val id: String,
val name: String,
val storageKg: Int,
val purity: String
)
// 2. ROOM DAO
@Dao
interface VibraniumDao {
@Query("SELECT * FROM vibranium_items")
fun getAllItems(): Flow<List<VibraniumItem>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<VibraniumItem>)
}
// 3. MOCK API QUE SIMULA RETROFIT
object WakandaApi {
fun fetchNetworkItems(): List<VibraniumItem> {
return listOf(
VibraniumItem("V01", "Vibranium Bruto (Mina Central)", 1500, "99.8%"),
VibraniumItem("V02", "Liga de Vibranium (Escudos)", 450, "95.5%"),
VibraniumItem("V03", "Microtecido Wakandano (Trajes)", 120, "99.9%")
)
}
}
// 4. OFFLINE FIRST REPOSITORY
class VibraniumRepository(private val dao: VibraniumDao) {
// A UI escuta APENAS os dados locais (Single Source of Truth)
val localItems: Flow<List<VibraniumItem>> = dao.getAllItems()
suspend fun refreshDatabase() {
// Simula chamada de rede e salva o resultado imediatamente no banco local (Room)
val networkData = WakandaApi.fetchNetworkItems()
dao.insertAll(networkData)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WakandaScreen() {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
// Inicialização didática local do banco Room (In-Memory para testes fáceis sem dependências)
val db = remember {
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
val dao = db.vibraniumDao()
val repository = remember { VibraniumRepository(dao) }
val itemsListState = repository.localItems.collectAsState(initial = emptyList())
var syncStatus by remember { mutableStateOf("Dados Locais (Offline)") }
Scaffold(
topBar = {
SmallTopAppBar(
title = { Text("WAKANDA DATABASE v1.0", fontWeight = FontWeight.Bold, letterSpacing = 2.sp) },
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = Color(0xFF130F1A),
titleContentColor = Color(0xFFA770EF)
)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.background(Color(0xFF07050A))
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(syncStatus, color = Color.Gray, fontSize = 12.sp)
Button(
onClick = {
coroutineScope.launch {
syncStatus = "Sincronizando com a API..."
repository.refreshDatabase()
syncStatus = "Banco Sincronizado com Sucesso! (Online)"
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFA770EF))
) {
Text("Puxar da API ⚡", color = Color.Black, fontWeight = FontWeight.Bold)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (itemsListState.value.isEmpty()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Nenhum item em cache local. Fique online para baixar.", color = Color.Gray)
}
} else {
LazyColumn(modifier = Modifier.weight(1f)) {
items(itemsListState.value) { item ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF130F1A)),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(item.name, fontWeight = FontWeight.Bold, color = Color.White)
Text(item.id, color = Color(0xFFA770EF), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
Text("Quantidade: ${item.storageKg}kg | Pureza: ${item.purity}", color = Color.Gray)
}
}
}
}
}
}
}
}
// 5. APP DATABASE DECLARATION
@Database(entities = [VibraniumItem::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun vibraniumDao(): VibraniumDao
}