💾 P06: Meus Jogos Favoritos (Catálogo Offline com Room)

Bem-vindo à sua primeira missão da Fase 4: Persistência & Dados (Offline First)! 💾

Neste projeto, vamos dar vida ao recurso mais cobiçado pelos jogadores: o recurso de Save Game! Você aprenderá a implementar o banco de dados Room, a biblioteca oficial de armazenamento interno offline do Google. Agora, os jogos cadastrados no aplicativo não desaparecerão mais quando você fechar a tela ou desligar o celular; eles ficarão salvos de forma permanente na memória do dispositivo!


✅ Pré-requisitos

Antes de começar, certifique-se de já ter estudado:


🎯 Objetivo do Projeto

Criar um catálogo de jogos pessoais (Game Backlog Manager) altamente premium de tema escuro. O app permite cadastrar o nome de um jogo, selecionar a plataforma/console em formato de chips interativos e salvar.

graph TD
    A[Interface do Usuário - Compose] -->|1. Ações: Adicionar/Deletar| B[JogoViewModel]
    B -->|2. Observa fluxo em tempo real| A
    B -->|3. Executa Coroutines suspend| C[JogoDao - Interface]
    C -->|4. Traduz para SQLite Queries| D[Room Database Engine]
    D -->|5. Escreve/Lê de forma Assíncrona| E[(SQLite File - Armazenamento Interno)]

📖 Dicionário do Projeto


🛠️ Passo 1: Configurando o Projeto no Android Studio

  1. Abra o Android Studio e crie um projeto usando o template Empty Activity (Compose padrão).
  2. Preencha as configurações:
    • Name: Game Backlog Room
    • Package Name: br.com.curso.colecaojogos
    • Language: Kotlin.
    • Minimum SDK: API 24 ou superior.
  3. Aguarde a inicialização do projeto.

🛠️ Passo 2: Configurando Dependências do Room (Gradle)

Para utilizar o Room, precisamos habilitar o gerador de códigos KSP ou KAPT do Kotlin.

  1. Abra Gradle Scripts > build.gradle (Module :app).
  2. No bloco de plugins no topo do arquivo, certifique-se de carregar o plugin do compilador Kotlin:
    plugins {
    ...
    id 'kotlin-kapt' // Adicione este plugin de compilação
    }
    
  3. No bloco dependencies, carregue as bibliotecas do Room e do ciclo de vida das views Compose:
    dependencies {
    // Room Database
    implementation "androidx.room:room-runtime:2.6.1"
    kapt "androidx.room:room-compiler:2.6.1"
    implementation "androidx.room:room-ktx:2.6.1"
    
    // Material Icons adicionais e ViewModel do Compose
    implementation "androidx.compose.material:material-icons-extended"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
    ...
    }
    

    Clique em Sync Now para baixar as novas dependências.


💾 Passo 3: Mapeando a Estrutura do Banco (JogoDatabase.kt)

Diferente do desenvolvimento legante, o Room agrupa a modelagem em um arquivo modular.

  1. Crie a pasta de pacotes: app > java > br.com.curso.colecaojogos > data > local.
  2. Crie o arquivo JogoDatabase.kt e cole a estrutura completa disponível no Gabarito. Ele conterá:
    • A @Entity val Jogo mapeando as colunas.
    • A interface @Dao JogoDao mapeando as queries SQL.
    • A classe abstrata central @Database JogoDatabase aplicando o padrão Singleton (Garante que só exista uma instância aberta do banco de dados na memória do celular).

🧠 Passo 4: Criando a Lógica Reativa (JogoViewModel.kt)

Para garantir que o app não sofra de travamentos, o Android Studio proíbe operações de banco de dados na Thread principal de visualização da tela. Usamos o viewModelScope.launch das Coroutines para rodar a inserção e a exclusão em Threads de background de forma totalmente invisível para o usuário.


🎨 Passo 5: Construindo a Interface Moderna (Compose UI)

Abra a sua MainActivity.kt em app > java > br.com.curso.colecaojogos > MainActivity.kt e codifique a interface completa fornecida no Gabarito.


🛠️ Requisitos Críticos de Configuração

1. Kapt / KSP ativado

Caso esqueça de adicionar o plugin id 'kotlin-kapt' nas configurações do Gradle, o compilador não conseguirá ler as anotações @Entity ou @Dao, gerando erro crítico de build.

2. Uso correto de suspend

As funções de gravação inserir e exclusão deletar do DAO contam com a palavra-chave suspend no início. Isso significa que elas são assíncronas e só rodam dentro do escopo de Coroutines (viewModelScope.launch).


🏆 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 ou corrigir problemas de compilação.

📄 Código de Banco de Dados Completo (JogoDatabase.kt)

Disponível em: app/src/main/java/br/com/curso/colecaojogos/data/local/JogoDatabase.kt

package br.com.curso.colecaojogos.data.local

import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow

// 1. A TABELA (Entity)
@Entity(tableName = "jogos")
data class Jogo(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val nome: String,
    val plataforma: String,
    val status: String = "Pendente" // Pendente, Jogando, Zerado
)

// 2. O CONTROLE REMOTO (DAO)
@Dao
interface JogoDao {
    @Insert
    suspend fun inserir(jogo: Jogo)

    @Query("SELECT * FROM jogos ORDER BY id DESC")
    fun listarTodos(): Flow<List<Jogo>>

    @Delete
    suspend fun deletar(jogo: Jogo)
}

// 3. A CENTRAL (Database)
@Database(entities = [Jogo::class], version = 1)
abstract class JogoDatabase : RoomDatabase() {
    abstract fun jogoDao(): JogoDao

    companion object {
        @Volatile
        private var INSTANCE: JogoDatabase? = null

        fun getDatabase(context: Context): JogoDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    JogoDatabase::class.java,
                    "jogo_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

📄 Código de Tela e Lógica Completa (MainActivity.kt)

Disponível em: app/src/main/java/br/com/curso/colecaojogos/MainActivity.kt

package br.com.curso.colecaojogos

import android.app.Application
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import br.com.curso.colecaojogos.data.local.Jogo
import br.com.curso.colecaojogos.data.local.JogoDatabase
import kotlinx.coroutines.launch

class JogoViewModel(application: Application) : AndroidViewModel(application) {
    private val db = JogoDatabase.getDatabase(application)
    private val dao = db.jogoDao()
    
    // Lista reativa que vigia o banco em tempo real
    val jogos = dao.listarTodos()

    fun salvar(nome: String, plataforma: String) {
        viewModelScope.launch {
            dao.inserir(Jogo(nome = nome, plataforma = plataforma))
        }
    }

    fun deletar(jogo: Jogo) {
        viewModelScope.launch {
            dao.deletar(jogo)
        }
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            GamerCollectionTheme {
                ColecaoApp()
            }
        }
    }
}

/**
 * Tema customizado Gamer para a listagem
 */
@Composable
fun GamerCollectionTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(
            primary = Color(0xFF00E676),
            secondary = Color(0xFF00B0FF),
            background = Color(0xFF0A0A0A),
            surface = Color(0xFF1A1A1A)
        ),
        content = content
    )
}

@Composable
fun ColecaoApp(viewModel: JogoViewModel = viewModel()) {
    var nomeJogo by remember { mutableStateOf("") }
    var plataforma by remember { mutableStateOf("PC") }
    
    // Coleta o fluxo reativo convertendo em Estado do Compose
    val listaJogos by viewModel.jogos.collectAsState(initial = emptyList())

    val backgroundGradient = Brush.verticalGradient(
        colors = listOf(Color(0xFF0F0C29), Color(0xFF302B63), Color(0xFF24243E))
    )

    Box(modifier = Modifier.fillMaxSize().background(backgroundGradient)) {
        Column(modifier = Modifier.padding(20.dp).fillMaxSize()) {
            Text(
                text = "GAME BACKLOG",
                fontSize = 32.sp,
                fontWeight = FontWeight.Black,
                color = Color.White,
                letterSpacing = 2.sp
            )
            
            Text(
                text = "GERENCIE SUA COLEÇÃO OFFLINE",
                fontSize = 12.sp,
                color = Color(0xFF00E676),
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(24.dp))

            // Formulário de Cadastro
            Card(
                colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.05f)),
                shape = RoundedCornerShape(20.dp)
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    OutlinedTextField(
                        value = nomeJogo,
                        onValueChange = { nomeJogo = it },
                        label = { Text("Nome do Jogo") },
                        modifier = Modifier.fillMaxWidth(),
                        colors = OutlinedTextFieldDefaults.colors(
                            unfocusedBorderColor = Color.Gray,
                            focusedBorderColor = Color(0xFF00E676)
                        )
                    )

                    Spacer(modifier = Modifier.height(12.dp))

                    // Chips Seletores de Plataforma
                    Row(
                        modifier = Modifier.fillMaxWidth(), 
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        listOf("PC", "PS5", "XBOX", "SWITCH").forEach { p ->
                            FilterChip(
                                selected = plataforma == p,
                                onClick = { plataforma = p },
                                label = { Text(p) },
                                colors = FilterChipDefaults.filterChipColors(
                                    selectedContainerColor = Color(0xFF00B0FF),
                                    selectedLabelColor = Color.Black
                                )
                            )
                        }
                    }

                    Spacer(modifier = Modifier.height(16.dp))

                    // Botão de Adição
                    Button(
                        onClick = {
                            if (nomeJogo.isNotEmpty()) {
                                viewModel.salvar(nomeJogo, plataforma)
                                nomeJogo = "" // Reseta o campo
                            }
                        },
                        modifier = Modifier.fillMaxWidth().height(50.dp),
                        shape = RoundedCornerShape(12.dp),
                        colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676))
                    ) {
                        Icon(Icons.Default.Add, contentDescription = null, tint = Color.Black)
                        Spacer(modifier = Modifier.width(8.dp))
                        Text("ADICIONAR À LISTA", fontWeight = FontWeight.Bold, color = Color.Black)
                    }
                }
            }

            Spacer(modifier = Modifier.height(24.dp))

            // Lista Reciclável Otimizada (LazyColumn)
            LazyColumn(modifier = Modifier.weight(1f)) {
                items(listaJogos, key = { it.id }) { jogo ->
                    GameCard(jogo) { viewModel.deletar(jogo) }
                }
            }
        }
    }
}

/**
 * Cartão customizado de representação da Coleção
 */
@Composable
fun GameCard(jogo: Jogo, onDelete: () -> Unit) {
    Card(
        modifier = Modifier.padding(vertical = 6.dp).fillMaxWidth(),
        colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A)),
        shape = RoundedCornerShape(16.dp)
    ) {
        ListItem(
            headlineContent = { 
                Text(jogo.nome, fontWeight = FontWeight.Bold, color = Color.White) 
            },
            supportingContent = { 
                Text(jogo.plataforma, color = Color.Gray, fontSize = 12.sp) 
            },
            leadingContent = {
                // Ícone Condicional de acordo com o console
                val icon = when(jogo.plataforma) {
                    "PC" -> Icons.Default.Computer
                    "PS5" -> Icons.Default.Gamepad
                    "XBOX" -> Icons.Default.VideogameAsset
                    else -> Icons.Default.SportsEsports
                }
                Icon(
                    icon, 
                    contentDescription = null, 
                    tint = Color(0xFF00B0FF), 
                    modifier = Modifier.size(32.dp)
                )
            },
            trailingContent = {
                IconButton(onClick = onDelete) {
                    Icon(Icons.Default.Delete, contentDescription = null, tint = Color(0xFFD32F2F))
                }
            },
            colors = ListItemDefaults.colors(containerColor = Color.Transparent)
        )
    }
}