📍 P09: PokeMap (Ginásios na Cidade com Google Maps)

Bem-vindo à segunda etapa da Fase 5: Conectando com o Mundo Físico! 📍

Neste projeto de alto impacto, o mundo real se tornará oficialmente o tabuleiro do seu jogo! Você aprenderá a integrar o receptor GPS do smartphone combinado com o Google Maps SDK para plotar pontos geográficos interativos. O objetivo é mapear ginásios de batalha Pokémon espalhados pelas coordenadas geográficas de sua cidade e criar interações reativas quando o usuário toca neles.


✅ Pré-requisitos

Antes de começar, certifique-se de já ter estudado:


🎯 Objetivo do Projeto

Criar um mapa dinâmico em tela cheia renderizando o terreno urbano da sua cidade em tempo real.

graph TD
    A[Início / Abrir App] --> B{Possui Permissão de GPS ACCESS_FINE_LOCATION?}
    B -- Não --> C[Solicitar permissão nativa em tempo de execução]
    C --> D{Concedida?}
    D -- Sim --> E[Inicializar Tela PokeMapScreen]
    D -- Não --> F[Exibir Tela de Permissão Necessária com Botão]
    B -- Sim --> E
    E --> G[Montar GoogleMap com properties myLocationEnabled]
    G --> H[Renderizar Marcadores de Ginásios no mapa]
    H --> I{Jogador clica em um Marcador/Gym?}
    I -- Sim --> J[Salvar selectedGym no estado & Revelar Card de Desafio]
    I -- Não --> H
    J --> K{Ação no Card?}
    K -- CANCELAR --> L[Resetar selectedGym = null & Ocultar Card]
    K -- BATALHAR --> M[Gatilho de Batalha de Ginásio]
    L --> H

📖 Dicionário do Projeto


🛠️ Passo 1: Habilitando Serviços no Google Cloud Platform (GCP)

Como o Google Maps é um serviço externo robusto, ele exige um registro gratuito no console do desenvolvedor.

  1. Acesse o Google Cloud Console.
  2. Crie ou selecione um projeto e acesse o menu APIs e Serviços.
  3. Pesquise por Maps SDK for Android e clique em Ativar.
  4. Vá em Credenciais > Criar Credenciais > Chave de API e copie a sua chave gerada.

🛠️ Passo 2: Configurando Permissões e Chave de API (Manifesto)

  1. Abra o arquivo app > src > main > AndroidManifest.xml.
  2. Adicione as permissões de localização necessárias no topo do arquivo: ```xml
3.  Adicione a sua Chave de API criada dentro da tag `<application>`:
```xml
<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="SUA_API_KEY_AQUI" />

🛠️ Passo 3: Carregando as Dependências no Gradle

  1. Abra Gradle Scripts > build.gradle (Module :app).
  2. Adicione as bibliotecas de mapas no bloco dependencies:
    dependencies {
    // Google Maps Compose e Play Services
    implementation "com.google.maps.android:maps-compose:4.3.0"
    implementation "com.google.android.gms:play-services-maps:18.2.0"
    implementation "com.google.android.gms:play-services-location:21.0.1"
    ...
    }
    

    Clique em Sync Now.


🎨 Passo 4: Solicitando Permissão de GPS (Compose UI)

Utilizaremos o launcher do Compose rememberLauncherForActivityResult com o contrato RequestPermission() para pedir a permissão ACCESS_FINE_LOCATION no momento em que a aplicação for carregada, exibindo uma tela de bloqueio com botão de concessão manual caso o usuário negue.


🎨 Passo 5: Desenhando o Mapa de Ginásios e Cards

No Compose, o mapa se comporta como qualquer outro elemento de UI. Renderizamos o componente GoogleMap passando as variáveis reativas properties e uiSettings.

Dentro do escopo do mapa, desenhamos múltiplos componentes Marker, capturando o seu callback de clique (onClick) para mudar o estado de uma variável reativa selectedGym e abrir um belo cartão sobreposto na base da tela com as ações de batalha.


🛠️ Requisitos Críticos de Configuração e Testes

1. SDK de Geolocalização Emulada

Ao realizar testes no Emulador do Android Studio, você pode emular posições de GPS reais acessando o painel de controle lateral do Emulador (Extended Controls > Location), digitando coordenadas geográficas de sua cidade (ex: São Paulo -23.5505, -46.6333) e clicando em Set Location.


🏆 Desafios para você (Upgrade!)

Se você terminou de programar a funcionalidade base e tudo está funcionando, experimente estes upgrades:

  • Distância até o Ginásio: Calcule e exiba a distância até cada ginásio usando Location.distanceTo().
  • Centralizar no Usuário: Adicione um botão que move a câmera do mapa para a posição atual do usuário.

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

Use os códigos Kotlin completos abaixo para validar sua implementação.

📄 Código de Lógica e Tela Completa (MainActivity.kt)

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

package br.com.curso.pokemap

import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.core.content.ContextCompat
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*

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

@Composable
fun PokeMapTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(
            primary = Color(0xFF00E676),
            secondary = Color(0xFF00B0FF),
            background = Color(0xFF121212)
        ),
        content = content
    )
}

@Composable
fun ScannerApp() {
    val context = LocalContext.current
    var hasLocationPermission by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.ACCESS_FINE_LOCATION
            ) == PackageManager.PERMISSION_GRANTED
        )
    }

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

    LaunchedEffect(key1 = true) {
        if (!hasLocationPermission) {
            launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
        }
    }

    if (hasLocationPermission) {
        PokeMapScreen()
    } else {
        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(
                    text = "Permissão de Localização Necessária",
                    color = Color.White,
                    textAlign = TextAlign.Center
                )
                Spacer(modifier = Modifier.height(16.dp))
                Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) {
                    Text("Conceder Permissão")
                }
            }
        }
    }
}

@Composable
fun PokeMapScreen() {
    val saoPaulo = LatLng(-23.5505, -46.6333)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(saoPaulo, 15f)
    }

    // Estado para controlar qual marcador foi clicado
    var selectedGym by remember { mutableStateOf<String?>(null) }

    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState,
            properties = MapProperties(isMyLocationEnabled = true),
            uiSettings = MapUiSettings(myLocationButtonEnabled = true)
        ) {
            // Ginásio 1
            Marker(
                state = MarkerState(position = LatLng(-23.5505, -46.6333)),
                title = "Ginásio Central",
                snippet = "Líder: Brock | Tipo: Rocha",
                onClick = {
                    selectedGym = "Ginásio Central"
                    false
                }
            )

            // Ginásio 2
            Marker(
                state = MarkerState(position = LatLng(-23.5520, -46.6350)),
                title = "Ginásio Aquático",
                snippet = "Líder: Misty | Tipo: Água",
                onClick = {
                    selectedGym = "Ginásio Aquático"
                    false
                }
            )
        }

        // Overlay de Informação do Ginásio Selecionado
        if (selectedGym != null) {
            Card(
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(16.dp)
                    .fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
                shape = RoundedCornerShape(16.dp)
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text(
                        text = selectedGym!!,
                        fontWeight = FontWeight.Black,
                        fontSize = 20.sp,
                        color = Color(0xFF00E676)
                    )
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = "Deseja desafiar este ginásio?",
                        color = Color.White.copy(alpha = 0.7f)
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                        TextButton(onClick = { selectedGym = null }) {
                            Text("CANCELAR", color = Color.Gray)
                        }
                        Button(
                            onClick = { /* Iniciar Batalha */ },
                            colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676))
                        ) {
                            Text("BATALHAR", color = Color.Black, fontWeight = FontWeight.Bold)
                        }
                    }
                }
            }
        }
    }
}