📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Android Nativo. Recomendado revisar antes de começar.

📷 P08: Batalha Scanner (QR Code Scanner com CameraX & ML Kit + Gerador PokéAPI)

Bem-vindo à Fase 4: Recursos Avançados e Integração de Hardware! 📷

Neste projeto prático, você aprenderá a transformar o smartphone em um leitor cibernético de alta performance e gerador de desafios de batalha, integrando o hardware de câmera em tempo real com Jetpack Compose, a biblioteca moderna CameraX, o poder de Visão Computacional do Google ML Kit Barcode Scanning, a geração nativa de QR Codes via ZXing e o carregamento de sprites oficiais da PokéAPI via Coil.

🤝 Dinâmica em Sala de Aula (Treinador vs Treinador)

O aplicativo possui Modo Dual com Abas:

  1. 🎒 Gerador de Desafio PokéAPI: O aluno escolhe seu Pokémon favorito (Pikachu, Charizard, Blastoise, Gengar, Mewtwo, etc.), define seu nome de Treinador e poder (HP), gerando um QR Code exclusivo na tela do smartphone.
  2. 📷 Scanner de Duelo: O colega ao lado aponta a câmera para o QR Code gerado, decodifica os dados em tempo real e inicia uma Arena de Batalha Interativa, com ações de ataque (⚡ ATACAR), defesa (🛡️ DEFENDER) e fuga (🏃 FUGIR)!

Fluxo Completo de Telas do Battle Scanner


📱 Galeria de Telas da Aplicação

1. Permissão & Fallback 2. Scanner Ativo (Mira Neon) 3. Batalha Detectada 4. Gerador de QR PokéAPI
Tela 1 - Permissão Tela 2 - Scanner Tela 3 - Batalha Tela 4 - Gerador
Controle resiliente com sincronização no ciclo de vida. Visor em tela cheia com mira cibernética verde. Arena de duelo P2P contra o Pokémon do colega. Geração offline de QR Codes com sprites da PokéAPI.

🎮 Guia de Como Jogar em Sala de Aula (Manual do Treinador)

Esta dinâmica foi desenhada para ser jogada em duplas ou grupos de alunos no laboratório de informática ou sala de aula, utilizando seus próprios celulares físicos.

Como Jogar o Duelo de QR Code em Sala de Aula


🕹️ Passo a Passo do Duelo

🔹 Fase 1: Criando seu Desafio (Aluno A)

  1. Abra o aplicativo Battle Scanner no seu aparelho.
  2. Na barra inferior, toque na aba “🎒 Gerar QR PokéAPI”.
  3. No campo “Seu Nome de Treinador(a)”, digite seu nome ou apelido (ex: Lucas, Maria).
  4. No carrossel horizontal de monstrinhos, selecione seu Pokémon favorito (ex: Charizard, Blastoise, Pikachu, Gengar, Mewtwo).
  5. Arraste o controle deslizante “Poder / HP” para definir a vida do seu Pokémon (entre 50 e 250 HP).
  6. Observe que o QR Code na parte inferior é atualizado em tempo real!
  7. Coloque o seu smartphone sobre a mesa com a tela virada para cima para que o colega ao lado possa escanear.

🔹 Fase 2: Escaneando e Iniciando a Batalha (Aluno B)

  1. No seu próprio smartphone, abra o aplicativo e selecione a aba “📷 Scanner de Duelo”.
  2. Caso seja a primeira execução, conceda a permissão de câmera (se necessário, use o atalho “Abrir Configurações do App”).
  3. Aponte a câmera para o QR Code na tela do aparelho do Aluno A.
  4. O leitor ultrarrápido do Google ML Kit detectará o código em milissegundos.
  5. A mira mudará para Vermelho Alerta e a Arena de Batalha P2P surgirá na tela com o Pokémon e o nome do seu colega!

🔹 Fase 3: As Ações de Combate na Arena

Ação Botão O que acontece na partida?
⚡ ATACAR Verde Neon Seu Pokémon desfere um ataque causando de 25 a 45 de dano no Pokémon do colega. O monstro adversário contra-ataca logo em seguida, causando de 15 a 30 de dano no seu defensor.
🛡️ DEFENDER Azul Ciano Seu monstro assume postura de escudo e recupera de 15 a 25 pontos de vida (HP), preparando-se para a próxima rodada.
🏆 VITÓRIA (Automático) Ao zerar o HP do Pokémon do colega (barra de progresso vazia), a tela exibe o anúncio: 🏆 VITÓRIA! Você derrotou o Pokémon de [Nome do Colega]!.
🏃 FUGIR Vermelho Encerra a batalha imediatamente, retornando ao visor do scanner com um cooldown de 2.5 segundos (tempo para você afastar a câmera do QR Code sem entrar em loop).

🏆 Sugestão de Torneio para a Turma (Gamificação)


✅ Pré-requisitos e Continuidade

Antes de iniciar este laboratório, certifique-se de ter compreendido:


🎯 Objetivos de Aprendizagem

Ao concluir este projeto autoguiado, você será capaz de:

  1. Gerenciar Permissões Modernas em Tempo de Execução: Solicitar permissão de câmera de forma reativa com Compose (rememberLauncherForActivityResult), contornar a supressão de pop-ups no Android 11+ com fallback para as Configurações do App, e sincronizar o status automaticamente no ON_RESUME.
  2. Integrar CameraX no Jetpack Compose: Utilizar o AndroidView para instanciar a PreviewView clássica e vinculá-la com segurança ao ciclo de vida da Activity (ProcessCameraProvider.bindToLifecycle).
  3. Processar Vídeo em Tempo Real com ML Kit: Configurar o ImageAnalysis em uma thread secundária dedicada (SingleThreadExecutor), converter frames ImageProxy para InputImage e evitar vazamento de memória liberando o buffer com imageProxy.close().
  4. Gerar QR Codes Nativamente com ZXing: Converter dados estruturados de batalha em um Bitmap de QR Code em alta resolução para exibição em tela no Jetpack Compose.
  5. Carregar Imagens da PokéAPI com Coil: Renderizar as ilustrações oficiais de monstrinhos em componentes declarativos assíncronos (AsyncImage).
  6. Construir um HUD Gamer e Arena de Duelos: Criar uma interface cyberpunk com barra de vida reativa, cálculo de turnos de combate e cooldown de fuga anti-looping.

🏗️ Arquitetura e Fluxo de Execução

graph TD
    A[Abrir Battle Scanner] --> B{Qual Aba Selecionada?}
    
    B -- Aba Gerador PokéAPI --> C[Aba QrGeneratorScreen]
    C --> D[Aluno escolhe Pokémon + Digita Nome + Ajusta HP]
    D --> E[ZXing gera Bitmap do QR Code offline]
    E --> F[Exibe QR Code na Tela para o Colega Escanear]
    
    B -- Aba Scanner de Duelo --> G{Possui Permissão CAMERA?}
    G -- Não --> H[Exibir Tela de Permissão com Fallback]
    H --> I[Usuário concede ou abre Configurações]
    I --> J[ON_RESUME detecta permissão -> Abre Câmera]
    G -- Sim --> J
    
    J --> K[CameraX PreviewView + ImageAnalysis ativos]
    K --> L[ML Kit decodifica frames de vídeo em background]
    L --> M[imageProxy.close obrigatorio para liberar buffer]
    L --> N{QR Code de Duelo Detectado?}
    N -- Sim --> O[Abre BattleArenaOverlay com Pokémon do Colega!]
    O --> P[Batalha Interativa: ATACAR / DEFENDER / FUGIR]
    P -- Fugir --> Q[Cooldown de 2.5s -> Retorna ao Scanner]
    N -- Não --> K

📖 Dicionário Técnico do Projeto

Termo / Componente O que é e para que serve?
CameraX Biblioteca oficial do Jetpack para controle de câmera que abstrai as diferenças de hardware de milhares de modelos de smartphones.
ProcessCameraProvider Singleton que gerencia a sessão de câmera e a acopla ao ciclo de vida da tela (LifecycleOwner).
PreviewView Componente nativo de visualização acelerado por hardware que recebe o sinal óptico da câmera e desenha os frames na tela.
ImageAnalysis Caso de uso do CameraX que intercepta o fluxo de vídeo frame a frame para análise em segundo plano.
ImageProxy Invólucro leve que contém o frame da câmera. Deve ser fechado explicitamente com .close() após o processamento.
Google ML Kit Barcode Scanning Biblioteca de visão computacional offline do Google capaz de ler e decodificar QR Codes e códigos de barras com altíssima velocidade.
ZXing (Zebra Crossing) Biblioteca open-source clássica para codificar strings em matrizes de bits e gerar imagens Bitmap de QR Codes offline.
Coil Compose Biblioteca moderna e leve de carregamento assíncrono de imagens para Jetpack Compose, utilizada para baixar os sprites da PokéAPI.
rememberLauncherForActivityResult Hook do Compose para registrar contratos assíncronos do Android (como RequestPermission()).
LifecycleEventObserver Observador do ciclo de vida que detecta eventos como ON_RESUME para revalidar permissões concedidas em segundo plano.

🛠️ Passo a Passo de Implementação

🚀 Passo 1: Criando o Novo Projeto no Android Studio

Para construir este projeto do zero no Android Studio, siga os passos de inicialização:

  1. Abra o Android Studio e clique em New Project (ou acesse File > New > New Project no menu superior).
  2. Na janela de escolha de templates, selecione Empty Activity (certifique-se de escolher o template com o ícone do Jetpack Compose 🌌).
  3. Preencha as configurações do projeto:
    • Name: Battle Scanner (ou android_p08_scanner_camerax)
    • Package name: br.com.curso.battlescanner
    • Save location: Escolha a pasta de destino do seu laboratório
    • Language: Kotlin
    • Minimum SDK: API 24 ("Nougat"; Android 7.0) ou superior
    • Build configuration language: Groovy DSL (build.gradle) ou Kotlin DSL (build.gradle.kts)
  4. Clique em Finish e aguarde o Gradle sincronizar as dependências e indexar o projeto pela primeira vez.

📄 Passo 2: Configurando o Manifesto (AndroidManifest.xml)

Abra app > src > main > AndroidManifest.xml. Adicione as permissões de câmera e internet (para carregar imagens da PokéAPI), declare os recursos de hardware como opcionais e inclua a meta-data do ML Kit:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Permissões em tempo de execução e rede -->
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.INTERNET" />

    <!-- Recursos de hardware opcionais (permite execução em emuladores e aparelhos diversos) -->
    <uses-feature
        android:name="android.hardware.camera"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.camera.autofocus"
        android:required="false" />

    <application
        android:allowBackup="true"
        android:label="Battle Scanner"
        android:supportsRtl="true"
        android:theme="@style/Theme.BattleScanner">
        
        <!-- Otimização ML Kit: Garante download/inicialização prévia do modelo de Barcode -->
        <meta-data
            android:name="com.google.mlkit.vision.DEPENDENCIES"
            android:value="barcode" />

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

📦 Passo 3: Adicionando as Dependências no Gradle (build.gradle)

Abra app > build.gradle e declare as bibliotecas do CameraX, ML Kit, ZXing e Coil:

dependencies {
    implementation platform('androidx.compose:compose-bom:2024.02.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.ui:ui-graphics'
    implementation 'androidx.compose.ui:ui-tooling-preview'
    implementation 'androidx.compose.material3:material3'
    implementation 'androidx.activity:activity-compose:1.8.2'
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
    
    // CameraX (Core, Camera2, Lifecycle e PreviewView)
    def camerax_version = "1.3.1"
    implementation "androidx.camera:camera-core:$camerax_version" implementation "androidx.camera:camera-camera2:$camerax_version"
    implementation "androidx.camera:camera-lifecycle:$camerax_version" implementation "androidx.camera:camera-view:$camerax_version"
    
    // Google ML Kit Barcode Scanning
    implementation 'com.google.mlkit:barcode-scanning:17.2.0'
    
    // Geração de QR Code Offline (ZXing)
    implementation 'com.google.zxing:core:3.5.3'
    
    // Carregamento de Sprites da PokéAPI no Compose (Coil)
    implementation 'io.coil-kt:coil-compose:2.5.0'
}

🎴 Passo 4: Modelo de Dados do Duelo e Gerador ZXing

Criamos o modelo PokemonChallenge com o método de codificação/decodificação de payloads e a função utilitária generateQrCodeBitmap para transformar qualquer texto em Bitmap offline via ZXing:

data class PokemonChallenge(
    val name: String,
    val pokeId: Int,
    val type: String,
    val hp: Int,
    val trainer: String,
    val spriteUrl: String = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$pokeId.png" ) { fun toQrPayload(): String { return "POKE_BATTLE|$name|$pokeId|$type|$hp|$trainer|$spriteUrl"
    }

    companion object {
        fun fromQrPayload(raw: String): PokemonChallenge? {
            if (!raw.startsWith("POKE_BATTLE")) return null
            val parts = raw.split("|")
            if (parts.size >= 7) {
                return PokemonChallenge(
                    name = parts[1],
                    pokeId = parts[2].toIntOrNull() ?: 25,
                    type = parts[3],
                    hp = parts[4].toIntOrNull() ?: 100,
                    trainer = parts[5],
                    spriteUrl = parts[6]
                )
            }
            return PokemonChallenge(
                name = "Mewtwo",
                pokeId = 150,
                type = "PSÍQUICO",
                hp = 180,
                trainer = "Chefão do Laboratório",
                spriteUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/150.png"
            )
        }
    }
}

fun generateQrCodeBitmap(content: String, size: Int = 512): Bitmap {
    val bitMatrix = MultiFormatWriter().encode(
        content,
        BarcodeFormat.QR_CODE,
        size,
        size
    )
    val width = bitMatrix.width
    val height = bitMatrix.height
    val pixels = IntArray(width * height)
    for (y in 0 until height) {
        val offset = y * width
        for (x in 0 until width) {
            pixels[offset + x] = if (bitMatrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE
        }
    }
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
    return bitmap
}

🎨 Passo 5: Criando o Tema Gamer Escuro (BattleScannerTheme)

@Composable
fun BattleScannerTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(
            primary = Color(0xFF00E676),    // Verde Neon
            secondary = Color(0xFF00B0FF),  // Azul Ciano
            tertiary = Color(0xFFFFD600),   // Amarelo Alerta
            error = Color(0xFFFF1744),      // Vermelho Batalha
            background = Color(0xFF0A0A0A), // Preto Profundo
            surface = Color(0xFF161616)
        ),
        content = content
    )
}

🔐 Passo 6: Gerenciador de Abas e Permissão Resiliente (ScannerApp)

A aplicação utiliza uma NavigationBar inferior com duas abas (Scanner de Duelo e Gerar QR PokéAPI) e controla a sincronização do ciclo de vida:

@Composable
fun ScannerApp() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    var selectedTab by remember { mutableStateOf(0) }

    var hasCameraPermission by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.CAMERA
            ) == PackageManager.PERMISSION_GRANTED
        )
    }

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { granted ->
            hasCameraPermission = granted
        }
    )

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                hasCameraPermission = ContextCompat.checkSelfPermission(
                    context,
                    Manifest.permission.CAMERA
                ) == PackageManager.PERMISSION_GRANTED
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    LaunchedEffect(Unit) {
        if (!hasCameraPermission) {
            launcher.launch(Manifest.permission.CAMERA)
        }
    }

    Scaffold(
        bottomBar = {
            NavigationBar(containerColor = Color(0xFF121212)) {
                NavigationBarItem(
                    selected = selectedTab == 0,
                    onClick = { selectedTab = 0 },
                    icon = { Text(if (selectedTab == 0) "📷" else "🔎", fontSize = 20.sp) },
                    label = { Text("Scanner de Duelo", fontWeight = FontWeight.Bold) }
                )
                NavigationBarItem(
                    selected = selectedTab == 1,
                    onClick = { selectedTab = 1 },
                    icon = { Text("🎒", fontSize = 20.sp) },
                    label = { Text("Gerar QR PokéAPI", fontWeight = FontWeight.Bold) }
                )
            }
        }
    ) { paddingValues ->
        Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
            if (selectedTab == 0) {
                if (hasCameraPermission) {
                    ScannerScreen()
                } else {
                    CameraPermissionDeniedScreen(
                        onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
                        onOpenSettings = {
                            val intent = Intent(
                                Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                                Uri.fromParts("package", context.packageName, null)
                            )
                            context.startActivity(intent)
                        }
                    )
                }
            } else {
                QrGeneratorScreen()
            }
        }
    }
}

Tela de Permissão de Câmera e Fallback


🎒 Passo 7: Aba do Gerador PokéAPI (QrGeneratorScreen)

Nesta tela, o aluno escolhe o Pokémon da PokéAPI em um carrossel horizontal estilizado (AsyncImage do Coil), digita seu apelido de treinador, ajusta o poder com um Slider e o aplicativo gera o QR Code na hora:

@Composable
fun QrGeneratorScreen() {
    var trainerName by remember { mutableStateOf("Treinador(a)") }
    var selectedPokemon by remember { mutableStateOf(POKEDEX_PRESETS[0]) }
    var customHp by remember { mutableStateOf(120f) }
    var qrBitmap by remember { mutableStateOf<Bitmap?>(null) }
    val scrollState = rememberScrollState()

    LaunchedEffect(selectedPokemon, trainerName, customHp) {
        val challenge = selectedPokemon.copy(
            trainer = trainerName.ifBlank { "Treinador" },
            hp = customHp.toInt()
        )
        qrBitmap = generateQrCodeBitmap(challenge.toQrPayload(), 600)
    }

    // Renderização com LazyRow de Pokémon, Slider e Card branco com Image(bitmap = qrBitmap.asImageBitmap())
    // ...
}

Aba do Gerador de Desafio PokéAPI


📷 Passo 8: Visor de Câmera (CameraPreview com CameraX)

Utilizamos o AndroidView para instanciar a PreviewView e configurar o ProcessCameraProvider com scaleType = FILL_CENTER e implementationMode = COMPATIBLE:

@Composable
fun CameraPreview(
    onBarcodeDetected: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
    val barcodeScanner = remember { BarcodeScanning.getClient() }

    DisposableEffect(Unit) {
        onDispose {
            cameraExecutor.shutdown()
            barcodeScanner.close()
        }
    }

    AndroidView(
        factory = { ctx ->
            val previewView = PreviewView(ctx).apply {
                scaleType = PreviewView.ScaleType.FILL_CENTER
                implementationMode = PreviewView.ImplementationMode.COMPATIBLE
            }
            val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)

            cameraProviderFuture.addListener({
                try {
                    val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

                    val preview = Preview.Builder().build().also {
                        it.setSurfaceProvider(previewView.surfaceProvider)
                    }

                    val imageAnalysis = ImageAnalysis.Builder()
                        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                        .build()
                        .also {
                            it.setAnalyzer(cameraExecutor) { imageProxy ->
                                processImageProxy(barcodeScanner, imageProxy, onBarcodeDetected)
                            }
                        }

                    val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                    cameraProvider.unbindAll()
                    cameraProvider.bindToLifecycle(
                        lifecycleOwner,
                        cameraSelector,
                        preview,
                        imageAnalysis
                    )
                } catch (exc: Exception) {
                    Log.e("CameraPreview", "Falha ao vincular CameraX ao ciclo de vida", exc)
                }
            }, ContextCompat.getMainExecutor(ctx))

            previewView
        },
        modifier = modifier
    )
}

Visor de Câmera com Mira Neon Ativa


🧠 Passo 9: Análise Inteligente de Frames com ML Kit

@androidx.annotation.OptIn(ExperimentalGetImage::class)
private fun processImageProxy(
    barcodeScanner: com.google.mlkit.vision.barcode.BarcodeScanner,
    imageProxy: ImageProxy,
    onBarcodeDetected: (String) -> Unit
) {
    val mediaImage = imageProxy.image
    if (mediaImage != null) {
        val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
        barcodeScanner.process(image)
            .addOnSuccessListener { barcodes ->
                for (barcode in barcodes) {
                    val rawValue = barcode.rawValue
                    if (!rawValue.isNullOrBlank()) {
                        onBarcodeDetected(rawValue)
                    }
                }
            }
            .addOnFailureListener { exc ->
                Log.e("QRAnalyzer", "Erro na análise de frame do ML Kit", exc)
            }
            .addOnCompleteListener {
                // REGRA DE OURO: Sempre libere o frame para o CameraX continuar alimentando o fluxo!
                imageProxy.close()
            }
    } else {
        imageProxy.close()
    }
}

⚔️ Passo 10: Arena de Batalha P2P em Tempo Real (BattleArenaOverlay)

Ao detectar um código POKE_BATTLE, a interface abre a arena de duelo com cálculo de dano aleatório, turno de defesa e o botão de fuga com cooldown:

@Composable
fun BattleArenaOverlay(
    enemy: PokemonChallenge,
    onFlee: () -> Unit
) {
    var enemyCurrentHp by remember { mutableStateOf(enemy.hp) }
    var myCurrentHp by remember { mutableStateOf(100) }
    var battleLog by remember { mutableStateOf("Batalha iniciada contra o Pokémon de ${enemy.trainer}!") }
    val myPokemon = remember { POKEDEX_PRESETS[0] } // Pikachu

    // Layout com cards de ambos os Pokémons (Coil), barras de vida reativas e botões ATACAR / DEFENDER / FUGIR
    // ...
}

Tela de Batalha Detectada com Alerta Vermelho


🔍 Guia de Diagnóstico & Resolução de Problemas (Troubleshooting)

Sintoma Observado Causa Provável Como Resolver
Ao tocar em “Conceder Permissão”, nada acontece e o diálogo não aparece O Android silenciou o diálogo nativo após recusas anteriores (“Don’t ask again”). Toque no botão “Abrir Configurações do App”, vá em Permissões > Câmera e marque Permitir. Ao voltar para o aplicativo, o LifecycleEventObserver (ON_RESUME) ativará a câmera instantaneamente.
Imagens dos Pokémons não carregam Falta de permissão de rede para o Coil acessar a internet. Adicione <uses-permission android:name="android.permission.INTERNET" /> no AndroidManifest.xml.
Tela preta ao abrir a câmera no Emulador O emulador do Android Studio não está configurado com câmera virtual ativa. Abra o AVD Manager no Android Studio, edite o emulador, vá em Advanced Settings > Camera e defina Back Camera como VirtualScene ou Webcam0.
O aplicativo lê 1 QR Code e depois a câmera congela Esquecimento da chamada imageProxy.close() no listener de conclusão. Certifique-se de que imageProxy.close() está dentro do bloco .addOnCompleteListener { ... } do ML Kit.
Ao clicar em “Fugir da Batalha”, a batalha reinicia imediatamente O QR Code continua na frente da câmera e o analisador processa a 30 FPS. O estado scanningPaused = true com delay(2500) pausa temporariamente a detecção, dando tempo de afastar o visor do código.

🧪 Banco de QR Codes para Testes de Laboratório

Você pode testar gerando códigos diretamente na aba “🎒 Gerar QR PokéAPI” do app de um colega ou utilizando geradores online com estes formatos:

Formato / Payload Origem Comportamento Esperado no Aplicativo
POKE_BATTLE\|Charizard\|6\|FOGO\|140\|Lucas\|https://... Gerador do Colega Abre a Arena de Batalha com a foto oficial do Charizard, nome do aluno e 140 HP.
POKE_BATTLE\|Blastoise\|9\|ÁGUA\|135\|Maria\|https://... Gerador do Colega Abre duelo contra o Blastoise da Maria.
POKE_BATTLE Payload Simples Ativa batalha contra o Boss clássico Mewtwo (180 HP).
POTION_HP_100 Item de Cura O HUD inferior exibe POTION_HP_100 mantendo a mira verde ativa.
https://github.com Link Genérico O leitor decodifica a URL e a exibe no status do HUD.

🏆 Desafios e Upgrades (Mão na Massa!)

Se você concluiu a implementação e duelou com seus colegas de sala, experimente estes upgrades:

  1. Vantagens Elementais: Multiplique o dano por 1.5x se o tipo do atacante for super efetivo (ex: Água vs Fogo, Fogo vs Planta, Elétrico vs Água).
  2. 🔍 Busca Dinâmica na PokéAPI: Adicione um campo de texto na aba do gerador para permitir digitar qualquer nome ou número da Pokédex (ex: Gengar, Lucario, Rayquaza) e buscar os dados online via Retrofit!
  3. 🔦 Lanterna / Flashlight: Adicione um botão no topo da tela do scanner para ligar/desligar a lanterna utilizando cameraControl.enableTorch(true/false).
  4. 📳 Feedback Háptico (Vibração): Ao iniciar a batalha ou acertar um ataque crítico, faça o smartphone vibrar suavemente usando Vibrator / VibrationEffect.

📖 Gabarito Oficial de Código (Para Conferência)

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

package br.com.curso.battlescanner

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
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
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import kotlin.random.Random

data class PokemonChallenge(
    val name: String,
    val pokeId: Int,
    val type: String,
    val hp: Int,
    val trainer: String,
    val spriteUrl: String = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$pokeId.png" ) { fun toQrPayload(): String { return "POKE_BATTLE|$name|$pokeId|$type|$hp|$trainer|$spriteUrl"
    }

    companion object {
        fun fromQrPayload(raw: String): PokemonChallenge? {
            if (!raw.startsWith("POKE_BATTLE")) return null
            val parts = raw.split("|")
            if (parts.size >= 7) {
                return PokemonChallenge(
                    name = parts[1],
                    pokeId = parts[2].toIntOrNull() ?: 25,
                    type = parts[3],
                    hp = parts[4].toIntOrNull() ?: 100,
                    trainer = parts[5],
                    spriteUrl = parts[6]
                )
            }
            return PokemonChallenge(
                name = "Mewtwo",
                pokeId = 150,
                type = "PSÍQUICO",
                hp = 180,
                trainer = "Chefão do Laboratório",
                spriteUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/150.png"
            )
        }
    }
}

val POKEDEX_PRESETS = listOf(
    PokemonChallenge("Pikachu", 25, "ELÉTRICO", 100, "Ash"),
    PokemonChallenge("Charizard", 6, "FOGO", 140, "Leon"),
    PokemonChallenge("Blastoise", 9, "ÁGUA", 135, "Gary"),
    PokemonChallenge("Venusaur", 3, "PLANTA", 130, "Red"),
    PokemonChallenge("Gengar", 94, "FANTASMA", 115, "Morty"),
    PokemonChallenge("Mewtwo", 150, "PSÍQUICO", 180, "Giovanni"),
    PokemonChallenge("Lucario", 448, "LUTADOR", 125, "Cynthia"),
    PokemonChallenge("Garchomp", 445, "DRAGÃO", 150, "Cynthia"),
    PokemonChallenge("Eevee", 133, "NORMAL", 90, "Penny")
)

fun generateQrCodeBitmap(content: String, size: Int = 512): Bitmap {
    val bitMatrix = MultiFormatWriter().encode(
        content,
        BarcodeFormat.QR_CODE,
        size,
        size
    )
    val width = bitMatrix.width
    val height = bitMatrix.height
    val pixels = IntArray(width * height)
    for (y in 0 until height) {
        val offset = y * width
        for (x in 0 until width) {
            pixels[offset + x] = if (bitMatrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE
        }
    }
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
    return bitmap
}

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

@Composable
fun BattleScannerTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(
            primary = Color(0xFF00E676),
            secondary = Color(0xFF00B0FF),
            tertiary = Color(0xFFFFD600),
            error = Color(0xFFFF1744),
            background = Color(0xFF0A0A0A),
            surface = Color(0xFF161616)
        ),
        content = content
    )
}

@Composable
fun ScannerApp() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    var selectedTab by remember { mutableStateOf(0) }

    var hasCameraPermission by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.CAMERA
            ) == PackageManager.PERMISSION_GRANTED
        )
    }

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { granted ->
            hasCameraPermission = granted
        }
    )

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                hasCameraPermission = ContextCompat.checkSelfPermission(
                    context,
                    Manifest.permission.CAMERA
                ) == PackageManager.PERMISSION_GRANTED
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    LaunchedEffect(Unit) {
        if (!hasCameraPermission) {
            launcher.launch(Manifest.permission.CAMERA)
        }
    }

    Scaffold(
        bottomBar = {
            NavigationBar(
                containerColor = Color(0xFF121212),
                contentColor = Color.White
            ) {
                NavigationBarItem(
                    selected = selectedTab == 0,
                    onClick = { selectedTab = 0 },
                    icon = { Text(if (selectedTab == 0) "📷" else "🔎", fontSize = 20.sp) },
                    label = { Text("Scanner de Duelo", fontWeight = FontWeight.Bold) },
                    colors = NavigationBarItemDefaults.colors(
                        selectedIconColor = Color(0xFF00E676),
                        selectedTextColor = Color(0xFF00E676),
                        indicatorColor = Color(0xFF00E676).copy(alpha = 0.2f),
                        unselectedTextColor = Color.Gray
                    )
                )
                NavigationBarItem(
                    selected = selectedTab == 1,
                    onClick = { selectedTab = 1 },
                    icon = { Text("🎒", fontSize = 20.sp) },
                    label = { Text("Gerar QR PokéAPI", fontWeight = FontWeight.Bold) },
                    colors = NavigationBarItemDefaults.colors(
                        selectedIconColor = Color(0xFF00B0FF),
                        selectedTextColor = Color(0xFF00B0FF),
                        indicatorColor = Color(0xFF00B0FF).copy(alpha = 0.2f),
                        unselectedTextColor = Color.Gray
                    )
                )
            }
        }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            if (selectedTab == 0) {
                if (hasCameraPermission) {
                    ScannerScreen()
                } else {
                    CameraPermissionDeniedScreen(
                        onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
                        onOpenSettings = {
                            val intent = Intent(
                                Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                                Uri.fromParts("package", context.packageName, null)
                            )
                            context.startActivity(intent)
                        }
                    )
                }
            } else {
                QrGeneratorScreen()
            }
        }
    }
}

@Composable
fun QrGeneratorScreen() {
    var trainerName by remember { mutableStateOf("Treinador(a)") }
    var selectedPokemon by remember { mutableStateOf(POKEDEX_PRESETS[0]) }
    var customHp by remember { mutableStateOf(120f) }
    var qrBitmap by remember { mutableStateOf<Bitmap?>(null) }
    val scrollState = rememberScrollState()

    LaunchedEffect(selectedPokemon, trainerName, customHp) {
        val challenge = selectedPokemon.copy(
            trainer = trainerName.ifBlank { "Treinador" },
            hp = customHp.toInt()
        )
        qrBitmap = generateQrCodeBitmap(challenge.toQrPayload(), 600)
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFF0A0A0A))
            .verticalScroll(scrollState)
            .padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "🎒 GERADOR DE DESAFIO POKÉAPI",
            fontSize = 18.sp,
            fontWeight = FontWeight.Black,
            color = Color(0xFF00B0FF),
            letterSpacing = 1.sp
        )
        Text(
            text = "Gere seu QR Code e mostre para seu colega escanear!",
            fontSize = 12.sp,
            color = Color.Gray,
            textAlign = TextAlign.Center
        )

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

        OutlinedTextField(
            value = trainerName,
            onValueChange = { trainerName = it },
            label = { Text("Seu Nome de Treinador(a)") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth(),
            colors = OutlinedTextFieldDefaults.colors(
                focusedBorderColor = Color(0xFF00B0FF),
                unfocusedBorderColor = Color(0xFF333333),
                focusedTextColor = Color.White,
                unfocusedTextColor = Color.White
            ),
            shape = RoundedCornerShape(12.dp)
        )

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

        Text(
            text = "Escolha seu Pokémon de Duelo:",
            fontSize = 13.sp,
            fontWeight = FontWeight.Bold,
            color = Color.White,
            modifier = Modifier.align(Alignment.Start)
        )
        Spacer(modifier = Modifier.height(8.dp))

        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(10.dp),
            modifier = Modifier.fillMaxWidth()
        ) {
            items(POKEDEX_PRESETS) { poke ->
                val isSelected = poke.name == selectedPokemon.name
                Card(
                    modifier = Modifier
                        .width(105.dp)
                        .clickable { selectedPokemon = poke }
                        .border(
                            2.dp,
                            if (isSelected) Color(0xFF00E676) else Color.Transparent,
                            RoundedCornerShape(14.dp)
                        ),
                    colors = CardDefaults.cardColors(
                        containerColor = if (isSelected) Color(0xFF1E293B) else Color(0xFF161616)
                    ),
                    shape = RoundedCornerShape(14.dp)
                ) {
                    Column(
                        modifier = Modifier.padding(8.dp),
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        AsyncImage(
                            model = poke.spriteUrl,
                            contentDescription = poke.name,
                            modifier = Modifier
                                .size(60.dp)
                                .clip(CircleShape)
                        )
                        Spacer(modifier = Modifier.height(4.dp))
                        Text(
                            text = poke.name,
                            fontSize = 11.sp,
                            fontWeight = FontWeight.Bold,
                            color = Color.White
                        )
                        Text(
                            text = poke.type,
                            fontSize = 9.sp,
                            color = Color(0xFF00E676),
                            fontWeight = FontWeight.SemiBold
                        )
                    }
                }
            }
        }

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

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("Poder / HP:", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Bold)
            Text("${customHp.toInt()} HP", fontSize = 14.sp, color = Color(0xFFFFD600), fontWeight = FontWeight.Black)
        }
        Slider(
            value = customHp,
            onValueChange = { customHp = it },
            valueRange = 50f..250f,
            steps = 20,
            colors = SliderDefaults.colors(
                thumbColor = Color(0xFF00B0FF),
                activeTrackColor = Color(0xFF00B0FF)
            )
        )

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

        Card(
            modifier = Modifier.fillMaxWidth(),
            colors = CardDefaults.cardColors(containerColor = Color.White),
            shape = RoundedCornerShape(20.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "DESAFIO DE ${trainerName.uppercase()}", fontSize = 14.sp, fontWeight = FontWeight.Black, color = Color.Black ) Text( text = "Pokémon:${selectedPokemon.name} (${selectedPokemon.type} • ${customHp.toInt()} HP)",
                    fontSize = 12.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color(0xFF0288D1)
                )

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

                qrBitmap?.let { bitmap ->
                    Image(
                        bitmap = bitmap.asImageBitmap(),
                        contentDescription = "QR Code de Duelo Pokémon",
                        modifier = Modifier
                            .size(230.dp)
                            .padding(4.dp)
                    )
                } ?: CircularProgressIndicator()

                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "📱 Aponte a câmera de outro celular aqui!",
                    fontSize = 11.sp,
                    color = Color.DarkGray,
                    fontWeight = FontWeight.SemiBold
                )
            }
        }
    }
}

@Composable
fun ScannerScreen() {
    var rawScanResult by remember { mutableStateOf("Aponte para o QR Code de um colega") }
    var activeChallenge by remember { mutableStateOf<PokemonChallenge?>(null) }
    var scanningPaused by remember { mutableStateOf(false) }
    val coroutineScope = rememberCoroutineScope()

    Box(modifier = Modifier.fillMaxSize()) {
        CameraPreview(
            onBarcodeDetected = { code ->
                if (!scanningPaused) {
                    rawScanResult = code
                    val challenge = PokemonChallenge.fromQrPayload(code)
                    if (challenge != null) {
                        activeChallenge = challenge
                    }
                }
            },
            modifier = Modifier.fillMaxSize()
        )

        if (activeChallenge != null) {
            BattleArenaOverlay(
                enemy = activeChallenge!!,
                onFlee = {
                    activeChallenge = null
                    rawScanResult = "Aponte para um QR Code"
                    scanningPaused = true
                    coroutineScope.launch {
                        delay(2500)
                        scanningPaused = false
                    }
                }
            )
        } else {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(50.dp)
                    .border(3.dp, Color(0xFF00E676), RoundedCornerShape(28.dp)),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "🎯 MIRA ATIVA: BUSCANDO DUELO",
                    color = Color(0xFF00E676),
                    fontWeight = FontWeight.Bold,
                    fontSize = 13.sp,
                    letterSpacing = 1.sp
                )
            }

            Column(
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .fillMaxWidth()
                    .background(Color.Black.copy(alpha = 0.85f))
                    .padding(horizontal = 24.dp, vertical = 20.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "STATUS DO SCANNER",
                    fontSize = 11.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = Color.Gray,
                    letterSpacing = 1.5.sp
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = rawScanResult,
                    fontSize = 15.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color.White,
                    textAlign = TextAlign.Center
                )
            }
        }
    }
}

@Composable
fun BattleArenaOverlay(
    enemy: PokemonChallenge,
    onFlee: () -> Unit
) {
    val context = LocalContext.current
    var enemyCurrentHp by remember { mutableStateOf(enemy.hp) }
    var myCurrentHp by remember { mutableStateOf(100) }
    var battleLog by remember { mutableStateOf("Batalha iniciada contra o Pokémon de ${enemy.trainer}!") }
    val myPokemon = remember { POKEDEX_PRESETS[0] }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.92f))
            .padding(16.dp)
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.SpaceBetween,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF1E0A0F)),
                border = androidx.compose.foundation.BorderStroke(2.dp, Color(0xFFFF1744)),
                shape = RoundedCornerShape(16.dp)
            ) {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(12.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(
                        text = "⚔️ DUELO P2P: DESAFIO DE ${enemy.trainer.uppercase()}! ⚔️",
                        color = Color(0xFFFF1744),
                        fontWeight = FontWeight.Black,
                        fontSize = 14.sp
                    )
                }
            }

            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A)),
                shape = RoundedCornerShape(16.dp)
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    AsyncImage(
                        model = enemy.spriteUrl,
                        contentDescription = enemy.name,
                        modifier = Modifier.size(90.dp)
                    )
                    Spacer(modifier = Modifier.width(12.dp))
                    Column {
                        Text(
                            text = enemy.name,
                            fontWeight = FontWeight.Black,
                            fontSize = 20.sp,
                            color = Color(0xFFFF1744)
                        )
                        Text(
                            text = "Tipo: ${enemy.type} | Treinador: ${enemy.trainer}",
                            fontSize = 12.sp,
                            color = Color.LightGray
                        )
                        Spacer(modifier = Modifier.height(6.dp))
                        Text(
                            text = "HP: $enemyCurrentHp / ${enemy.hp}",
                            fontSize = 13.sp,
                            fontWeight = FontWeight.Bold,
                            color = Color.White
                        )
                        LinearProgressIndicator(
                            progress = { (enemyCurrentHp.toFloat() / enemy.hp).coerceIn(0f, 1f) },
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(8.dp)
                                .clip(RoundedCornerShape(4.dp)),
                            color = if (enemyCurrentHp > enemy.hp * 0.3f) Color(0xFF00E676) else Color(0xFFFF1744),
                            trackColor = Color(0xFF333333)
                        )
                    }
                }
            }

            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF141414)),
                shape = RoundedCornerShape(12.dp)
            ) {
                Text(
                    text = battleLog,
                    modifier = Modifier.padding(14.dp),
                    fontSize = 13.sp,
                    color = Color(0xFFFFD600),
                    fontWeight = FontWeight.SemiBold,
                    textAlign = TextAlign.Center
                )
            }

            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF122018)),
                shape = RoundedCornerShape(16.dp)
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    AsyncImage(
                        model = myPokemon.spriteUrl,
                        contentDescription = myPokemon.name,
                        modifier = Modifier.size(80.dp)
                    )
                    Spacer(modifier = Modifier.width(12.dp))
                    Column {
                        Text(
                            text = "Meu: ${myPokemon.name}", fontWeight = FontWeight.Black, fontSize = 18.sp, color = Color(0xFF00E676) ) Text( text = "HP:$myCurrentHp / 100",
                            fontSize = 13.sp,
                            fontWeight = FontWeight.Bold,
                            color = Color.White
                        )
                        LinearProgressIndicator(
                            progress = { (myCurrentHp.toFloat() / 100f).coerceIn(0f, 1f) },
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(8.dp)
                                .clip(RoundedCornerShape(4.dp)),
                            color = Color(0xFF00E676),
                            trackColor = Color(0xFF333333)
                        )
                    }
                }
            }

            Column(
                modifier = Modifier.fillMaxWidth(),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    Button(
                        onClick = {
                            if (enemyCurrentHp <= 0) {
                                Toast.makeText(context, "O Pokémon já foi derrotado!", Toast.LENGTH_SHORT).show()
                                return@Button
                            }
                            val myDamage = Random.nextInt(25, 45)
                            val newEnemyHp = (enemyCurrentHp - myDamage).coerceAtLeast(0)
                            enemyCurrentHp = newEnemyHp

                            if (newEnemyHp <= 0) {
                                battleLog = "🏆 VITÓRIA! Você derrotou o ${enemy.name} de ${enemy.trainer}!"
                            } else {
                                val enemyDamage = Random.nextInt(15, 30)
                                myCurrentHp = (myCurrentHp - enemyDamage).coerceAtLeast(0)
                                battleLog = "⚡ Você causou $myDamage de dano! ${enemy.name} contra-atacou causando $enemyDamage de dano!"
                            }
                        },
                        modifier = Modifier.weight(1f),
                        colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676)),
                        shape = RoundedCornerShape(12.dp)
                    ) {
                        Text("⚡ ATACAR", color = Color.Black, fontWeight = FontWeight.Black)
                    }

                    Button(
                        onClick = {
                            val heal = Random.nextInt(15, 25)
                            myCurrentHp = (myCurrentHp + heal).coerceAtMost(100)
                            battleLog = "🛡️ Você usou Defesa e recuperou $heal de HP!"
                        },
                        modifier = Modifier.weight(1f),
                        colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00B0FF)),
                        shape = RoundedCornerShape(12.dp)
                    ) {
                        Text("🛡️ DEFENDER", color = Color.Black, fontWeight = FontWeight.Black)
                    }
                }

                Button(
                    onClick = onFlee,
                    modifier = Modifier.fillMaxWidth(),
                    colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF1744)),
                    shape = RoundedCornerShape(12.dp)
                ) {
                    Text("🏃 FUGIR DA BATALHA", color = Color.White, fontWeight = FontWeight.Bold)
                }
            }
        }
    }
}

@Composable
fun CameraPermissionDeniedScreen(
    onRequestPermission: () -> Unit,
    onOpenSettings: () -> Unit
) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFF0A0A0A))
            .padding(24.dp),
        contentAlignment = Alignment.Center
    ) {
        Card(
            modifier = Modifier.fillMaxWidth(),
            colors = CardDefaults.cardColors(containerColor = Color(0xFF181818)),
            shape = RoundedCornerShape(20.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
        ) {
            Column(
                modifier = Modifier.padding(24.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(text = "📷", fontSize = 48.sp)
                Spacer(modifier = Modifier.height(12.dp))
                Text(
                    text = "Permissão de Câmera Necessária",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color(0xFF00E676),
                    textAlign = TextAlign.Center
                )
                Spacer(modifier = Modifier.height(12.dp))
                Text(
                    text = "Para escanear QR Codes de batalha e duelar contra seus colegas de classe, o aplicativo precisa de acesso à câmera do smartphone.",
                    fontSize = 14.sp,
                    color = Color.White.copy(alpha = 0.8f),
                    textAlign = TextAlign.Center,
                    lineHeight = 20.sp
                )
                Spacer(modifier = Modifier.height(24.dp))

                Button(
                    onClick = onRequestPermission,
                    modifier = Modifier.fillMaxWidth(),
                    colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676)),
                    shape = RoundedCornerShape(12.dp)
                ) {
                    Text("CONCEDER PERMISSÃO", color = Color.Black, fontWeight = FontWeight.Bold)
                }

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

                OutlinedButton(
                    onClick = onOpenSettings,
                    modifier = Modifier.fillMaxWidth(),
                    shape = RoundedCornerShape(12.dp),
                    colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF00B0FF))
                ) {
                    Text("ABRIR CONFIGURAÇÕES DO APP")
                }

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

                Text(
                    text = "💡 Dica: Se o diálogo do Android não abrir, toque em 'Abrir Configurações', marque 'Câmera' e volte. O app abrirá a câmera automaticamente!",
                    fontSize = 12.sp,
                    color = Color.Gray,
                    textAlign = TextAlign.Center,
                    lineHeight = 16.sp
                )
            }
        }
    }
}

@Composable
fun CameraPreview(
    onBarcodeDetected: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
    val barcodeScanner = remember { BarcodeScanning.getClient() }

    DisposableEffect(Unit) {
        onDispose {
            cameraExecutor.shutdown()
            barcodeScanner.close()
        }
    }

    AndroidView(
        factory = { ctx ->
            val previewView = PreviewView(ctx).apply {
                scaleType = PreviewView.ScaleType.FILL_CENTER
                implementationMode = PreviewView.ImplementationMode.COMPATIBLE
            }
            val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)

            cameraProviderFuture.addListener({
                try {
                    val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

                    val preview = Preview.Builder().build().also {
                        it.setSurfaceProvider(previewView.surfaceProvider)
                    }

                    val imageAnalysis = ImageAnalysis.Builder()
                        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                        .build()
                        .also {
                            it.setAnalyzer(cameraExecutor) { imageProxy ->
                                processImageProxy(barcodeScanner, imageProxy, onBarcodeDetected)
                            }
                        }

                    val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                    cameraProvider.unbindAll()
                    cameraProvider.bindToLifecycle(
                        lifecycleOwner,
                        cameraSelector,
                        preview,
                        imageAnalysis
                    )
                } catch (exc: Exception) {
                    Log.e("CameraPreview", "Falha ao vincular CameraX ao ciclo de vida", exc)
                }
            }, ContextCompat.getMainExecutor(ctx))

            previewView
        },
        modifier = modifier
    )
}

@androidx.annotation.OptIn(ExperimentalGetImage::class)
private fun processImageProxy(
    barcodeScanner: com.google.mlkit.vision.barcode.BarcodeScanner,
    imageProxy: ImageProxy,
    onBarcodeDetected: (String) -> Unit
) {
    val mediaImage = imageProxy.image
    if (mediaImage != null) {
        val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
        barcodeScanner.process(image)
            .addOnSuccessListener { barcodes ->
                for (barcode in barcodes) {
                    val rawValue = barcode.rawValue
                    if (!rawValue.isNullOrBlank()) {
                        onBarcodeDetected(rawValue)
                    }
                }
            }
            .addOnFailureListener { exc ->
                Log.e("QRAnalyzer", "Erro na análise de frame do ML Kit", exc)
            }
            .addOnCompleteListener {
                imageProxy.close()
            }
    } else {
        imageProxy.close()
    }
}

🚀 Como Executar no Laboratório

1. Abra o projeto no Android Studio ou VS Code

No Android Studio, selecione File > Open… e aponte para a pasta deste projeto:

proj_aplicacoes_full_stack/projetos/android_p08_scanner_camerax

2. Conecte o Celular Físico ou Inicie o Emulador

3. Compile e Execute a Aplicação

./gradlew assembleDebug

Ou simplesmente clique no botão Run ‘app’ (Shift + F10) no Android Studio.

[!TIP] Navegação via Terminal a partir da Raiz:

cd proj_aplicacoes_full_stack/projetos/android_p08_scanner_camerax