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


🎯 Objetivo do Jogo

Construir uma máquina de estados reutilizável que gerencia três telas distintas:

  1. Tela de Início (StartScreen): Apresentação clássica do jogo com um botão “PRESS START” para iniciar.
  2. 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.
  3. 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


🛠️ Passo 1: Configurando o Projeto no Android Studio

  1. Abra o Android Studio e clique em New Project.
  2. Escolha o template Empty Activity (Certifique-se de ser o template com o símbolo reativo do Jetpack Compose).
  3. Configure as informações iniciais:
    • Name: Quiz Gamer Compose
    • Package Name: br.com.curso.quizgamer
    • Language: Kotlin.
    • Minimum SDK: API 24 ou superior.
  4. Clique em Finish e aguarde o Gradle sincronizar os pacotes.

🖼️ Passo 2: Importando Recursos Visuais (Assets)

  1. Consiga as imagens trophy.png e gameover.png (você pode copiar os arquivos já presentes na pasta app/src/main/res/drawable deste projeto).
  2. 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:

  1. StartScreen: Centraliza o título “QUIZ GAMER” em fontes ultra-negrito e o botão para iniciar a jornada.
  2. 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.
  3. 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:

Tela inicial do Quiz Gamer (nível Hardcore)


🏆 Desafios para você (Upgrade!)


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