🎮 P05: Quiz Gamer: O Teste de Conhecimento (Jetpack Compose)
Bem-vindo à fase final da Fase 3: O Futuro é Agora (Declarativo)! 🚀 Neste projeto, vamos subir ainda mais o nível construindo um aplicativo de jogo completo: o Quiz Gamer Hardcore.
Você aprenderá a lidar com a engrenagem mais poderosa do Jetpack Compose: o Estado Reativo (State) e o gerenciamento inteligente de telas. Criaremos um fluxo dinâmico de perguntas e respostas com feedback de acerto/erro instantâneo, animações de transição de tela e um painel de vitória ou derrota dramática.
✅ Pré-requisitos
Antes de começar, certifique-se de já ter estudado:
- 📘 Cap 09: Vida Reativa (Estado no Compose)
- 📘 Cap 11: Navegação entre Fases (Menus)
- 🏗️ Projeto anterior: P04: Cartão de Treinador Pokémon
🎯 Objetivo do Jogo
Construir uma máquina de estados reutilizável que gerencia três telas distintas:
- Tela de Início (
StartScreen): Apresentação clássica do jogo com um botão “PRESS START” para iniciar. - Tela do Quiz (
QuizScreen): Uma rodada de 5 perguntas gamers clássicas. Exibe uma barra de progresso horizontal neon, uma caixa com a pergunta centralizada e 4 botões de alternativas clicáveis.- Feedback Instantâneo: Ao clicar em uma alternativa, o botão clicado muda de cor na hora (Verde se correto, Vermelho se incorreto) e exibe um ícone correspondente (check ou close). Todos os outros botões são travados até o jogador clicar no botão de rodada “PRÓXIMA”.
- Cálculo de XP: Cada resposta correta soma 20 pontos (XP) ao jogador.
- Tela de Resultado (
ResultScreen): Exibe ilustrações e textos personalizados baseados na pontuação final do jogador:- Pontuação >= 60 XP (Vitória): Exibe a ilustração de um troféu dourado e o título “VITÓRIA!”.
- Pontuação < 60 XP (Derrota): Exibe a ilustração de uma caveira de Game Over e o título vermelho “GAME OVER”.
- Um botão “TENTAR NOVAMENTE” reinicia todo o estado do quiz para o jogador tentar obter um placar perfeito.
graph TD
A[Tela Start: PRESS START] --> B[Clique em Jogar]
B --> C[Tela Quiz: Inicializar Pergunta Index = 0]
C --> D[Exibir Pergunta & 4 Alternativas]
D --> E[Jogador seleciona resposta]
E --> F{Acertou?}
F -- Sim --> G[Botão selecionado fica Verde + Check Ícone & Soma 20 XP]
F -- Não --> H[Botão selecionado fica Vermelho + Close Ícone]
G --> I[Revelar Botão PRÓXIMA]
H --> I
I --> J[Clique em PRÓXIMA]
J --> K{É a última pergunta?}
K -- Não --> L[Perguntas index++ & Limpar seleções]
L --> D
K -- Sim --> M[Mudar para Tela de Resultado]
M --> N{Score >= 60 XP?}
N -- Sim --> O[VITÓRIA! Exibir Troféu]
N -- Não --> P[GAME OVER! Exibir GameOver Caveira]
O --> Q[Botão Tentar Novamente -> Tela Start]
P --> Q
📖 Dicionário do Projeto
- State (Estado Reativo): Variáveis que servem como a memória viva da interface. Usamos
remember { mutableStateOf(...) }para que o Compose monitore as mudanças em dados (como a pontuação e qual tela exibir) e atualize os visuais do app automaticamente. - Recomposition (Recomposição): O processo inteligente em que o Compose redesenha de forma cirúrgica apenas os elementos de layout que dependem de uma variável de estado que acabou de ser alterada.
- AnimatedContent: API moderna de animação do Compose usada para realizar transições fluidas de tela (como fades ou slides) ao alterar o estado do gerenciador de rotas.
- LinearProgressIndicator: Barra de progresso horizontal do Android Jetpack para sinalizar visualmente ao usuário a etapa atual em relação ao total de perguntas do quiz.
- ButtonDefaults.buttonColors: Configuração do Compose que permite alterar dinamicamente a cor de fundo de um botão baseado em um estado condicional (ex: verde para acerto, vermelho para erro).
- Pair / listOf: Estrutura prática de coleções do Kotlin (
"Pergunta" to listOf("Opção 1", "Opção 2")) que nos permite modelar perguntas e respostas sem criar arquivos extras de modelo.
🛠️ Passo 1: Configurando o Projeto no Android Studio
- Abra o Android Studio e clique em
New Project. - Escolha o template Empty Activity (Certifique-se de ser o template com o símbolo reativo do Jetpack Compose).
- Configure as informações iniciais:
- Name:
Quiz Gamer Compose - Package Name:
br.com.curso.quizgamer - Language: Kotlin.
- Minimum SDK: API 24 ou superior.
- Name:
- Clique em Finish e aguarde o Gradle sincronizar os pacotes.
🖼️ Passo 2: Importando Recursos Visuais (Assets)
- Consiga as imagens
trophy.pngegameover.png(você pode copiar os arquivos já presentes na pastaapp/src/main/res/drawabledeste projeto). - Cole-as na pasta do seu novo projeto em:
app > src > main > res > drawable.
🎨 Passo 3: Criando a Estrutura Base e Temas do Jogo
O Compose permite aplicar um tema personalizado escuro de forma muito simplificada. Vamos abrir nossa MainActivity.kt, limpar o código gerado pelo assistente e definir o nosso QuizGamerTheme e o nosso gerenciador de telas animadas QuizManager.
Abra MainActivity.kt e digite/cole a estrutura completa conforme fornecido no Gabarito ao final do manual.
🎨 Passo 4: Construindo as Telas de Jogo
Nós dividiremos nossa interface em três composables especialistas:
StartScreen: Centraliza o título “QUIZ GAMER” em fontes ultra-negrito e o botão para iniciar a jornada.QuizScreen: A tela mais inteligente. Monitora as perguntas atuais, calcula a barra de progresso, altera as cores dos botões de resposta clicados de forma condicional para verde/vermelho e libera o botão de avançar de pergunta.ResultScreen: Tela final reativa que analisa se os acertos superaram o limite mínimo de 60 XP. Caso positivo, renderiza o troféu de ouro de vitória; caso negativo, a ilustração vermelha de Game Over.
🛠️ Requisitos de Configuração e Solução de Problemas
1. ViewBinding e Compose
Lembre-se de que no Compose não utilizamos arquivos XML de layout e, portanto, não habilitamos ViewBinding e não declaramos activity_main.xml. Todo o desenho visível ocorre 100% de forma direta e moderna em código Kotlin com anotações @Composable.
📸 Resultado Esperado
Veja como sua tela deve ficar ao final deste projeto:

🏆 Desafios para você (Upgrade!)
- Temporizador regressivo: Adicione uma barra ou círculo regressivo de 10 segundos por pergunta. Se o tempo esgotar, o jogador erra automaticamente e avança para a próxima pergunta!
📖 Gabarito Oficial de Código (Para Conferência)
Use o código Kotlin completo abaixo para validar sua implementação ou corrigir problemas de compilação na sua MainActivity.kt.
📄 Código Kotlin Completo (MainActivity.kt)
Disponível em: app/src/main/java/br/com/curso/quizgamer/MainActivity.kt
package br.com.curso.quizgamer
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
QuizGamerTheme {
QuizManager()
}
}
}
}
/**
* Tema customizado escuro com tons roxos e cianos neon
*/
@Composable
fun QuizGamerTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFFBB86FC),
secondary = Color(0xFF03DAC6),
background = Color(0xFF121212)
),
content = content
)
}
/**
* Gerenciador de Estados e Transições de Tela
*/
@Composable
fun QuizManager() {
var screenState by remember { mutableStateOf("start") }
var score by remember { mutableStateOf(0) }
// Fundo Premium degradê azul marinho
val backgroundGradient = Brush.verticalGradient(
colors = listOf(Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364))
)
Box(modifier = Modifier.fillMaxSize().background(backgroundGradient)) {
AnimatedContent(
targetState = screenState,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "ScreenTransition"
) { targetScreen ->
when (targetScreen) {
"start" -> StartScreen { screenState = "quiz" }
"quiz" -> QuizScreen(
onFinished = { finalScore ->
score = finalScore
screenState = "result"
}
)
"result" -> ResultScreen(score) {
score = 0
screenState = "start"
}
}
}
}
}
/**
* Tela de Início
*/
@Composable
fun StartScreen(onStart: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "QUIZ GAMER",
fontSize = 42.sp,
fontWeight = FontWeight.Black,
color = Color.White,
letterSpacing = 4.sp
)
Text(
text = "LEVEL: HARDCORE",
fontSize = 14.sp,
color = Color(0xFF03DAC6),
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth().height(60.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFBB86FC))
) {
Text("PRESS START", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black)
}
}
}
/**
* Tela do Jogo (Quiz)
*/
@Composable
fun QuizScreen(onFinished: (Int) -> Unit) {
// 1. Database de Perguntas e Respostas
val questions = listOf(
"Quem é o protagonista da série 'The Legend of Zelda'?" to listOf("Zelda", "Link", "Ganon", "Epona"),
"Qual console foi apelidado de 'Project Reality'?" to listOf("PS1", "Nintendo 64", "Sega Saturn", "Dreamcast"),
"Em qual ano o primeiro PlayStation foi lançado no Japão?" to listOf("1992", "1993", "1994", "1995"),
"Qual o nome do criador do Mario e Donkey Kong?" to listOf("Hideo Kojima", "Shigeru Miyamoto", "Masahiro Sakurai", "Todd Howard"),
"Qual o jogo mais vendido de todos os tempos?" to listOf("Minecraft", "GTA V", "Tetris", "Wii Sports")
)
val correctAnswers = listOf(1, 1, 2, 1, 0) // Índices das respostas corretas
// 2. Estados reativos locais
var currentQuestionIdx by remember { mutableStateOf(0) }
var scoreLocal by remember { mutableStateOf(0) }
var selectedAnswer by remember { mutableStateOf<Int?>(null) }
var isCorrect by remember { mutableStateOf<Boolean?>(null) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Barra de Progresso Neon
LinearProgressIndicator(
progress = { (currentQuestionIdx + 1).toFloat() / questions.size },
modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
color = Color(0xFF03DAC6),
trackColor = Color.White.copy(alpha = 0.1f)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "PERGUNTA ${currentQuestionIdx + 1}",
color = Color(0xFFBB86FC),
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(16.dp))
// Card da Pergunta
Card(
modifier = Modifier.fillMaxWidth().height(120.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.05f)),
shape = RoundedCornerShape(24.dp)
) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Text(
text = questions[currentQuestionIdx].first,
fontSize = 20.sp,
color = Color.White,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Renderização Dinâmica das Alternativas
questions[currentQuestionIdx].second.forEachIndexed { index, answer ->
val buttonColor = when {
selectedAnswer == index && isCorrect == true -> Color(0xFF4CAF50) // Verde se acertou
selectedAnswer == index && isCorrect == false -> Color(0xFFF44336) // Vermelho se errou
else -> Color.White.copy(alpha = 0.1f)
}
Button(
onClick = {
if (selectedAnswer == null) {
selectedAnswer = index
isCorrect = index == correctAnswers[currentQuestionIdx]
if (isCorrect!!) scoreLocal += 20
}
},
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = buttonColor),
enabled = selectedAnswer == null // Trava cliques após a primeira escolha
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = answer,
color = if (selectedAnswer == index) Color.White else Color.White.copy(alpha = 0.8f),
modifier = Modifier.weight(1f)
)
if (selectedAnswer == index) {
Icon(
imageVector = if (isCorrect!!) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = Color.White
)
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Botão para avançar
if (selectedAnswer != null) {
Button(
onClick = {
if (currentQuestionIdx < questions.size - 1) {
currentQuestionIdx++
selectedAnswer = null
isCorrect = null
} else {
onFinished(scoreLocal)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF03DAC6))
) {
Text("PRÓXIMA", fontWeight = FontWeight.Bold, color = Color.Black)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
/**
* Tela de Resultados Reativa
*/
@Composable
fun ResultScreen(score: Int, onRestart: () -> Unit) {
val isWinner = score >= 60
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Exibe troféu ou gameover dinamicamente
Image(
painter = painterResource(id = if (isWinner) R.drawable.trophy else R.drawable.gameover),
contentDescription = null,
modifier = Modifier.size(180.dp)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = if (isWinner) "VITÓRIA!" else "GAME OVER",
fontSize = 36.sp,
fontWeight = FontWeight.Black,
color = if (isWinner) Color(0xFFFFA000) else Color(0xFFD32F2F)
)
Text(
text = "Sua pontuação final: $score XP",
fontSize = 18.sp,
color = Color.White.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onRestart,
modifier = Modifier.fillMaxWidth().height(60.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("TENTAR NOVAMENTE", fontWeight = FontWeight.Bold)
}
}
}