📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Android Nativo. Recomendado revisar antes de começar.
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.
O aplicativo possui Modo Dual com Abas:
⚡ ATACAR), defesa (🛡️ DEFENDER) e fuga (🏃 FUGIR)!
| 1. Permissão & Fallback | 2. Scanner Ativo (Mira Neon) | 3. Batalha Detectada | 4. Gerador de QR PokéAPI |
|---|---|---|---|
| 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. |
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.
Lucas, Maria).Charizard, Blastoise, Pikachu, Gengar, Mewtwo).| 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). |
Antes de iniciar este laboratório, certifique-se de ter compreendido:
Ao concluir este projeto autoguiado, você será capaz de:
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.AndroidView para instanciar a PreviewView clássica e vinculá-la com segurança ao ciclo de vida da Activity (ProcessCameraProvider.bindToLifecycle).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().Bitmap de QR Code em alta resolução para exibição em tela no Jetpack Compose.AsyncImage).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
| 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. |
Para construir este projeto do zero no Android Studio, siga os passos de inicialização:
File > New > New Project no menu superior).Battle Scanner (ou android_p08_scanner_camerax)br.com.curso.battlescannerKotlinAPI 24 ("Nougat"; Android 7.0) ou superiorGroovy DSL (build.gradle) ou Kotlin DSL (build.gradle.kts)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>
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'
}
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
}
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
)
}
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()
}
}
}
}
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())
// ...
}
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
)
}
@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()
}
}
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
// ...
}
| 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. |
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. |
Se você concluiu a implementação e duelou com seus colegas de sala, experimente estes upgrades:
1.5x se o tipo do atacante for super efetivo (ex: Água vs Fogo, Fogo vs Planta, Elétrico vs Água).Gengar, Lucario, Rayquaza) e buscar os dados online via Retrofit!cameraControl.enableTorch(true/false).Vibrator / VibrationEffect.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()
}
}
No Android Studio, selecione File > Open… e aponte para a pasta deste projeto:
proj_aplicacoes_full_stack/projetos/android_p08_scanner_camerax
VirtualScene (para simular uma sala 3D com QR Code) ou Webcam0 (para usar a webcam do seu computador)../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