💜 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:


🎯 Objetivo do Projeto

Estruturar o pipeline arquitetural de dados de forma resiliente contendo:

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


🛠️ 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.

  1. Crie a classe VibraniumRepository.kt no 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> = dao.getAllItems()

// 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:


📖 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
}