💾 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:
- 📘 Cap 10: Inventário e Pokedex (LazyColumn)
- 📘 Cap 12: Arquitetura de Jogo (MVVM)
- 📘 Cap 15: Meus Jogos Favoritos (Room DB)
- 🏗️ Projeto anterior: P05: Quiz Gamer
🎯 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.
- Armazenamento Inteligente: Os registros de jogos cadastrados são inseridos no banco local SQLite via biblioteca Room de forma assíncrona.
- Fluxo em Tempo Real: A listagem de jogos na tela é alimentada por um canal ativo (
Flow), o que faz com que a interface se atualize instantaneamente a cada cadastro ou exclusão de jogo. - Lista Otimizada: Exibição dos itens em uma lista rolável inteligente (
LazyColumn) que detecta o console cadastrado e exibe ícones representativos (Computador, Gamepad, Switch) de forma customizada com botões para exclusão direta.
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
- Room Database: A biblioteca oficial do Google que fornece uma camada de abstração limpa sobre o banco SQLite clássico, eliminando a escrita manual de queries verbosas em formato String e oferecendo validação de banco direto na compilação.
- Entity (@Entity): A anotação que diz ao Room que a classe Kotlin modelada (
Jogo) será mapeada para uma tabela física de banco de dados SQLite. Cada campo vira uma coluna. - DAO (@Dao - Data Access Object): Uma interface especial que funciona como o controle remoto de ações do banco de dados. Mapeia de forma explícita as ações de inserção, exclusão e busca.
- AndroidViewModel: Uma extensão especializada da classe
ViewModeltradicional que possui acesso ao contexto global do aplicativo, permitindo inicializar com segurança o banco de dados interno Android. - Flow (Fluxos Kotlin): Um emissor de fluxos reativos em tempo real. Ao fazer o Room retornar um
Flow<List<Jogo>>, a interface reage sozinha a qualquer operação no banco de dados, sem necessidade de consultas manuais redundantes. - LazyColumn: O equivalente super-moderno do antigo
RecyclerView. Renderiza listas grandes ou infinitas reciclando elementos visuais na tela na velocidade da luz para economizar processamento e memória do celular. - FilterChip: Componente do Material Design 3 que renderiza tags clicáveis interativas excelentes para seleções e filtros.
- ListItem: Layout padrão do Google para linhas de listas que posiciona de forma profissional imagens à esquerda, textos em duas linhas e ações de controle na direita.
🛠️ Passo 1: Configurando o Projeto no Android Studio
- Abra o Android Studio e crie um projeto usando o template Empty Activity (Compose padrão).
- Preencha as configurações:
- Name:
Game Backlog Room - Package Name:
br.com.curso.colecaojogos - Language: Kotlin.
- Minimum SDK: API 24 ou superior.
- Name:
- 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.
- Abra
Gradle Scripts > build.gradle (Module :app). - 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 } - 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.
- Crie a pasta de pacotes:
app > java > br.com.curso.colecaojogos > data > local. - Crie o arquivo
JogoDatabase.kte cole a estrutura completa disponível no Gabarito. Ele conterá:- A
@Entity val Jogomapeando as colunas. - A interface
@Dao JogoDaomapeando as queries SQL. - A classe abstrata central
@Database JogoDatabaseaplicando o padrão Singleton (Garante que só exista uma instância aberta do banco de dados na memória do celular).
- A
🧠 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:
- Busca/Filtro: Adicione um
OutlinedTextFieldno topo da tela para filtrar a lista de jogos pelo nome digitado. - Nota do Jogo: Adicione um campo
nota(0-10) naEntitye exiba-o como texto ou estrelas na lista.
📖 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)
)
}
}