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

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

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

Neste projeto prático de alto impacto, o mundo real se tornará oficialmente o tabuleiro do seu jogo! Você aprenderá a integrar o hardware de GPS (Satélites de Geolocalização) do smartphone com o Google Maps SDK for Android e o moderno Maps Compose no Jetpack Compose.

O objetivo é mapear ginásios de batalha Pokémon espalhados pelas coordenadas geográficas de sua cidade, rastrear a localização física do jogador em tempo real (com o clássico ponto azul pulsante do Google Maps) e abrir cartões interativos de desafio contra Líderes de Ginásio (como Brock e Misty)!

Fluxo Completo de Telas do PokeMap


📱 Galeria de Telas da Aplicação

1. Permissão & Fallback GPS 2. Mapa Ativo com Ponto Azul 3. Desafio de Ginásio (Card)
Tela 1 - Permissão GPS Tela 2 - Mapa Ativo Tela 3 - Desafio de Ginásio
Sincronização de permissão em tempo real no ciclo de vida. Visor em tela cheia com GPS em tempo real e marcadores. Overlay interativo com líder, insígnia e botão de combate.

✅ 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 de Localização no Android 12+ (API 31+): Solicitar ACCESS_FINE_LOCATION e ACCESS_COARSE_LOCATION simultaneamente via RequestMultiplePermissions(), tratar a supressão de pop-ups com atalho para as Configurações do App e sincronizar o status no ON_RESUME.
  2. Verificar Serviços de Localização do Dispositivo: Detectar se o GPS de hardware está ligado via LocationManager e guiar o usuário com um toque para ACTION_LOCATION_SOURCE_SETTINGS.
  3. Obter a Posição Real do Usuário (FusedLocationProviderClient): Capturar as coordenadas de latitude/longitude reais do aparelho e animar a câmera do mapa (CameraUpdateFactory.newLatLngZoom) suavemente.
  4. Integrar o Google Maps SDK no Jetpack Compose: Utilizar o pacote oficial maps-compose (GoogleMap, Marker, MarkerState, MapProperties, MapUiSettings).
  5. Configurar Chave de Demonstração do Google Maps: Utilizar a chave de demonstração gratuita oficial sem necessidade de cartão de crédito.
  6. Construir um HUD de Desafio de Ginásios: Renderizar marcadores customizados e um card inferior de ação para iniciar disputas.

🏗️ Arquitetura e Fluxo de Execução

graph TD
    A[Abrir Aplicativo PokeMap] --> B{Possui Permissões de GPS?}
    B -- Não --> C[Exibir LocationPermissionDeniedScreen]
    C --> D[Usuário toca em Conceder Permissão]
    D --> E{Diálogo do Android aceito?}
    E -- Sim --> F{GPS do Dispositivo está Ligado?}
    E -- Não / Bloqueado --> G[Usuário toca em Abrir Configurações do App]
    G --> H[Usuário concede permissão nas Configurações]
    H --> I[Usuário retorna ao App -> ON_RESUME detecta permissão!]
    I --> F
    F -- Não --> J[Exibir Botão: Ligar GPS nas Configurações]
    J --> K[Usuário ativa GPS -> ON_RESUME detecta GPS ativo!]
    K --> L[Inicializar PokeMapScreen]
    F -- Sim --> L
    B -- Sim --> F
    
    L --> M[FusedLocationProviderClient obtém LatLng do Usuário]
    M --> N[CameraPositionState anima câmera para a posição do Usuário]
    L --> O[GoogleMap renderiza terreno urbano com ponto azul]
    L --> P[Gera lista de Ginásios em torno da posição do Treinador]
    P --> Q[Plotar Marcadores Marker no Mapa]
    Q --> R{Jogador clica em um Ginásio?}
    R -- Sim --> S[Abre Card Inferior com Líder, Tipo e Insígnia]
    R -- Não --> O
    S --> T{Ação no Card?}
    T -- FECHAR --> U[selectedGym = null -> Oculta Card]
    T -- DESAFIAR GINÁSIO --> V[Toast / Gatilho de Batalha com o Líder]
    U --> O

🔑 Obtenção e Configuração Segura da Chave do Google Maps

[!TIP]

🎁 Chave de Demonstração Oficial do Google Maps (Gratuita e Sem Cartão)

Para facilitar os estudos e permitir que alunos executem o projeto em sala de aula sem precisar cadastrar dados financeiros, o Google disponibiliza a Chave de Demonstração do Maps Platform:

🌐 Acesse: https://mapsplatform.google.com/maps-demo-key/

  1. Acesse o link acima no navegador.
  2. Clique em “Get Demo Key” (Obter Chave de Demonstração).
  3. Copie a chave de API gerada.

🛡️ Boas Práticas de Segurança: Chave Fora do Código com .env ou local.properties

[!IMPORTANT] Nunca comite chaves de API diretamente no código-fonte ou no AndroidManifest.xml! Se o projeto for enviado para o GitHub, sua chave fica exposta. No ecossistema Android profissional, mantemos a chave em um arquivo .env ou local.properties na raiz do projeto (ambos já listados no .gitignore).

O Gradle lê o arquivo e injeta a chave automaticamente em tempo de compilação via manifestPlaceholders!

  1. Na raiz do projeto android_p09_pokemap_gps/, crie o arquivo .env (ou use o local.properties):
    MAPS_API_KEY=AIzaSy_SUA_CHAVE_DEMO_AQUI
    
  2. No app/build.gradle, o Gradle carrega a chave e injeta no manifesto: ```groovy // 🔐 Carrega a chave de API de forma segura a partir do .env ou local.properties Properties properties = new Properties() File localProps = project.rootProject.file(‘local.properties’) if (localProps.exists()) properties.load(localProps.newDataInputStream()) File envFile = project.rootProject.file(‘.env’) if (envFile.exists()) properties.load(envFile.newDataInputStream())

String mapsApiKey = properties.getProperty(‘MAPS_API_KEY’, ‘CHAVE_NAO_CONFIGURADA’)

android { … defaultConfig { … manifestPlaceholders = [MAPS_API_KEY: mapsApiKey] buildConfigField “String”, “MAPS_API_KEY”, “"${mapsApiKey}"” } }


3. No `AndroidManifest.xml`, o manifesto referencia a variável `${MAPS_API_KEY}`: ```xml <meta-data android:name="com.google.android.geo.API_KEY" android:value="${MAPS_API_KEY}" />

📖 Dicionário Técnico do Projeto

Termo / Componente O que é e para que serve?
Google Maps SDK for Android Biblioteca de serviços de mapas do Google que renderiza terrenos vetoriais, trânsito e imagens de satélite nativamente.
maps-compose Kit oficial do Google que traduz o SDK clássico de mapas em componentes 100% declarativos para Jetpack Compose.
manifestPlaceholders Recurso do Gradle que injeta variáveis e chaves de ambiente dinamicamente no AndroidManifest.xml durante a compilação.
LatLng Objeto geográfico que armazena as coordenadas de Latitude (Norte/Sul) e Longitude (Leste/Oeste).
CameraPositionState Estado reativo do Compose que controla a posição, inclinação, rotação e o nível de zoom da câmera do mapa.
CameraUpdateFactory Fábrica utilitária que gera comandos de animação e movimentação suave da câmera do mapa.
FusedLocationProviderClient Cliente oficial do Google Play Services que combina satélites de GPS, Wi-Fi e redes móveis para entregar a melhor coordenada possível.
Marker / MarkerState Alfinete geográfico desenhado sobre o mapa para representar pontos de interesse (ginásios, pokéstops, monstrinhos).
MapProperties (isMyLocationEnabled) Propriedade que ativa a leitura contínua do GPS para desenhar o ponto azul na posição real do jogador.
LifecycleEventObserver (ON_RESUME) Observador de ciclo de vida que detecta o retorno do usuário das Configurações do Android e atualiza a interface instantaneamente.

🛠️ Passo a Passo de Implementação

🚀 Passo 1: Criando o Novo Projeto no Android Studio

  1. Abra o Android Studio e clique em New Project (ou acesse File > New > New Project).
  2. Selecione o template Empty Activity (com o ícone do Jetpack Compose 🌌).
  3. Preencha as configurações do projeto:
    • Name: PokeMap (ou android_p09_pokemap_gps)
    • Package name: br.com.curso.pokemap
    • 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 a indexação inicial do Gradle.

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

Abra app > src > main > AndroidManifest.xml. Declare as permissões de localização precisa (ACCESS_FINE_LOCATION), aproximada (ACCESS_COARSE_LOCATION), internet e a tag com a variável injetada ${MAPS_API_KEY}:

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

    <!-- Permissões de Localização (GPS) e Conexão de Rede -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:label="PokeMap"
        android:supportsRtl="true"
        android:theme="@style/Theme.PokeMap">
        
        <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>

        <!-- Chave injetada com segurança a partir do .env / local.properties -->
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />

    </application>

</manifest>

📦 Passo 3: Adicionando as Dependências e Configuração no Gradle (build.gradle)

Abra app > build.gradle e declare o carregamento do .env e as bibliotecas do Google Maps Compose, Play Services Maps e Location:

// 🔐 Carrega a chave de API de forma segura a partir do .env ou local.properties (fora do Git)
Properties properties = new Properties()
File localProps = project.rootProject.file('local.properties')
if (localProps.exists()) properties.load(localProps.newDataInputStream())
File envFile = project.rootProject.file('.env')
if (envFile.exists()) properties.load(envFile.newDataInputStream())

String mapsApiKey = properties.getProperty('MAPS_API_KEY', 'CHAVE_NAO_CONFIGURADA')

android {
    ...
    defaultConfig {
        ...
        manifestPlaceholders = [
            MAPS_API_KEY: mapsApiKey
        ]
        buildConfigField "String", "MAPS_API_KEY", "\"${mapsApiKey}\""
    }
}

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'
    
    // Google Maps Compose e Play Services Location
    implementation 'com.google.maps.android:maps-compose:4.3.3'
    implementation 'com.google.android.gms:play-services-maps:18.2.0'
    implementation 'com.google.android.gms:play-services-location:21.1.0'
}

🎴 Passo 4: Modelo de Dados do Ginásio (PokemonGym)

No topo de MainActivity.kt, criamos o modelo de dados para representar os pontos geográficos dos ginásios:

data class PokemonGym(
    val id: String,
    val name: String,
    val leader: String,
    val type: String,
    val badge: String,
    val position: LatLng,
    val color: Color = Color(0xFF00E676)
)

🎨 Passo 5: Tema Gamer Escuro (PokeMapTheme)

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

🔐 Passo 6: Gerenciador Reativo de GPS com Sincronização no Ciclo de Vida (PokeMapApp)

[!IMPORTANT] Por que o botão de permissão pode não responder em celulares físicos? No Android 11+ / 14+, se o usuário rejeitou a permissão de localização anteriormente, chamadas repetidas a launcher.launch() são silenciadas pelo sistema operacional.

A Solução Profissional:

  1. Solicitar simultaneamente ACCESS_FINE_LOCATION e ACCESS_COARSE_LOCATION via RequestMultiplePermissions().
  2. Oferecer o atalho para as Configurações do Aplicativo (Settings.ACTION_APPLICATION_DETAILS_SETTINGS).
  3. Verificar se o serviço de GPS está ligado no aparelho e oferecer atalho para Settings.ACTION_LOCATION_SOURCE_SETTINGS.
  4. Registrar um LifecycleEventObserver que monitora ON_RESUME. Assim que o usuário volta das configurações com a permissão concedida, o app abre o mapa instantaneamente!
@Composable
fun PokeMapApp() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    fun checkLocationPermissions(): Boolean {
        val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
        val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
        return fine || coarse
    }

    fun checkGpsEnabled(): Boolean {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
                locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
    }

    var hasLocationPermission by remember { mutableStateOf(checkLocationPermissions()) }
    var isGpsEnabled by remember { mutableStateOf(checkGpsEnabled()) }

    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions(),
        onResult = { permissions ->
            val fine = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
            val coarse = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false
            hasLocationPermission = fine || coarse
            isGpsEnabled = checkGpsEnabled()
        }
    )

    // Sincronização automática no retorno das Configurações do Android
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                hasLocationPermission = checkLocationPermissions()
                isGpsEnabled = checkGpsEnabled()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    LaunchedEffect(Unit) {
        if (!hasLocationPermission) {
            permissionLauncher.launch(
                arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
            )
        }
    }

    if (hasLocationPermission && isGpsEnabled) {
        PokeMapScreen()
    } else {
        LocationPermissionDeniedScreen(
            hasPermission = hasLocationPermission,
            isGpsEnabled = isGpsEnabled,
            onRequestPermission = {
                permissionLauncher.launch(
                    arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
                )
            },
            onOpenAppSettings = {
                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))
                context.startActivity(intent)
            },
            onOpenLocationSettings = {
                val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
                context.startActivity(intent)
            }
        )
    }
}

Tela de Permissão de GPS e Fallback


🗺️ Passo 7: Mapa com GPS Real (PokeMapScreen + FusedLocationProviderClient)

Ao abrir a tela, o app utiliza o FusedLocationProviderClient para capturar as coordenadas físicas do smartphone e animar a câmera suavemente para a posição real do estudante:

@Composable
fun PokeMapScreen() {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    val defaultLocation = LatLng(-23.5505, -46.6333)
    var userLocation by remember { mutableStateOf<LatLng?>(null) }
    var selectedGym by remember { mutableStateOf<PokemonGym?>(null) }

    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(defaultLocation, 15f)
    }

    val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }

    // Busca a posição real do GPS e move a câmera
    LaunchedEffect(Unit) {
        try {
            if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                fusedLocationClient.lastLocation.addOnSuccessListener { loc ->
                    if (loc != null) {
                        val currentLatLng = LatLng(loc.latitude, loc.longitude)
                        userLocation = currentLatLng
                        coroutineScope.launch {
                            cameraPositionState.animate(
                                CameraUpdateFactory.newCameraPosition(
                                    CameraPosition.fromLatLngZoom(currentLatLng, 16f)
                                )
                            )
                        }
                    }
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    // Gera lista de ginásios dinâmicos ao redor da posição do jogador
    val basePos = userLocation ?: defaultLocation
    val gymList = remember(basePos) {
        listOf(
            PokemonGym("gym_1", "Ginásio Central", "Brock", "ROCHA", "Insígnia da Rocha", LatLng(basePos.latitude + 0.0020, basePos.longitude + 0.0025)),
            PokemonGym("gym_2", "Ginásio Aquático", "Misty", "ÁGUA", "Insígnia da Cascata", LatLng(basePos.latitude - 0.0025, basePos.longitude - 0.0020)),
            PokemonGym("gym_3", "Ginásio Elétrico", "Lt. Surge", "ELÉTRICO", "Insígnia do Trovão", LatLng(basePos.latitude + 0.0035, basePos.longitude - 0.0030))
        )
    }

    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState,
            properties = MapProperties(isMyLocationEnabled = true),
            uiSettings = MapUiSettings(myLocationButtonEnabled = true, compassEnabled = true)
        ) {
            gymList.forEach { gym ->
                Marker(
                    state = MarkerState(position = gym.position),
                    title = gym.name,
                    snippet = "Líder: ${gym.leader} | Tipo: ${gym.type}",
                    onClick = {
                        selectedGym = gym
                        false
                    }
                )
            }
        }
        
        // Header e Card de Desafio...
    }
}

Mapa com GPS Ativo e Ginásios


⚔️ Passo 8: Card Inferior de Desafio de Ginásio

Ao tocar em qualquer marcador de ginásio no mapa, o card sobreposto é revelado na base da tela:

selectedGym?.let { gym ->
    Card(
        modifier = Modifier
            .align(Alignment.BottomCenter)
            .padding(16.dp)
            .fillMaxWidth(),
        colors = CardDefaults.cardColors(containerColor = Color(0xFF181818)),
        shape = RoundedCornerShape(20.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
    ) {
        Column(modifier = Modifier.padding(20.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(gym.name, fontWeight = FontWeight.Black, fontSize = 20.sp, color = Color(0xFF00E676))
                Surface(shape = RoundedCornerShape(8.dp), color = Color(0xFF00B0FF).copy(alpha = 0.2f)) {
                    Text(gym.type, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), fontSize = 11.sp, color = Color(0xFF00B0FF), fontWeight = FontWeight.Bold)
                }
            }
            Spacer(modifier = Modifier.height(6.dp))
            Text("Líder: ${gym.leader}", fontSize = 14.sp, color = Color.White, fontWeight = FontWeight.SemiBold) Text("Recompensa:${gym.badge}", fontSize = 12.sp, color = Color.Gray)
            Spacer(modifier = Modifier.height(18.dp))
            Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
                OutlinedButton(onClick = { selectedGym = null }, modifier = Modifier.weight(1f)) {
                    Text("FECHAR", color = Color.LightGray)
                }
                Button(
                    onClick = {
                        Toast.makeText(context, "⚔️ Batalha contra ${gym.leader} iniciada!", Toast.LENGTH_SHORT).show()
                        selectedGym = null
                    },
                    modifier = Modifier.weight(1.5f),
                    colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676))
                ) {
                    Text("DESAFIAR GINÁSIO", color = Color.Black, fontWeight = FontWeight.Black)
                }
            }
        }
    }
}

Card de Desafio de Ginásio


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

Sintoma Observado Causa Provável Como Resolver
O mapa exibe uma tela cinza vazia com a logo do Google A Chave de API do Google Maps está ausente, inválida ou não ativada. Acesse https://mapsplatform.google.com/maps-demo-key/, copie sua Demo Key gratuita e cole no AndroidManifest.xml na tag com.google.android.geo.API_KEY.
Ao tocar em “Conceder Permissão”, nada acontece O Android silenciou o diálogo após recusas anteriores (“Don’t ask again”). Toque em “Abrir Configurações do App”, vá em Permissões > Localização e marque Permitir. Ao voltar para o aplicativo, o ON_RESUME carregará o mapa automaticamente.
Aparece a mensagem “GPS do Dispositivo Desligado” O serviço de localização está desativado no topo do smartphone. Toque em “Ligar GPS nas Configurações” e habilite a chavinha de Localização do aparelho.
O ponto azul do jogador não aparece no mapa Falta da propriedade isMyLocationEnabled = true ou permissão revogada. Certifique-se de que properties = MapProperties(isMyLocationEnabled = true) está configurado no GoogleMap.
No Emulador, o mapa não se move para o local real O emulador não possui satélites GPS reais. No painel lateral do Emulador, vá em Extended Controls (…) > Location, digite as coordenadas desejadas e clique em Set Location.

🧪 Banco de Coordenadas para Teste no Emulador

Para simular o jogador em diferentes cidades do mundo pelo Android Studio:

Localidade Latitude Longitude Cenário de Teste
São Paulo (Marco Zero) -23.5505 -46.6333 Posição padrão do laboratório
Rio de Janeiro (Copacabana) -22.9698 -43.1869 Ginásio à beira-mar
Tokyo (Shibuya) 35.6595 139.7004 Metrópole Pokémon
Nova York (Times Square) 40.7580 -73.9855 Ginásio Central Urbano
Londres (Big Ben) 51.5007 -0.1246 Ginásio Histórico

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

  1. 📏 Cálculo de Distância Real: Utilize android.location.Location.distanceBetween() para calcular a distância exata em metros entre a posição do jogador e o ginásio, exibindo "A 150m de distância" no card!
  2. 🎯 Raio de Acesso (Geofencing): Permita clicar em “DESAFIAR GINÁSIO” apenas se o jogador estiver a menos de 100 metros físicos do ginásio!
  3. 🐾 Pokémons Selvagens no Mapa: Adicione marcadores extras representando monstrinhos selvagens que surgem dinamicamente no mapa para captura.
  4. 🛰️ Alternar Tipo de Mapa: Adicione um botão para alternar entre MapType.NORMAL, MapType.SATELLITE e MapType.HYBRID.

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

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

package br.com.curso.pokemap

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
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.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.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.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*
import kotlinx.coroutines.launch

data class PokemonGym(
    val id: String,
    val name: String,
    val leader: String,
    val type: String,
    val badge: String,
    val position: LatLng,
    val color: Color = Color(0xFF00E676)
)

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

@Composable
fun PokeMapTheme(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 PokeMapApp() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    fun checkLocationPermissions(): Boolean {
        val fineGranted = ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
        val coarseGranted = ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_COARSE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
        return fineGranted || coarseGranted
    }

    fun checkGpsEnabled(): Boolean {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
                locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
    }

    var hasLocationPermission by remember { mutableStateOf(checkLocationPermissions()) }
    var isGpsEnabled by remember { mutableStateOf(checkGpsEnabled()) }

    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions(),
        onResult = { permissions ->
            val fine = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
            val coarse = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false
            hasLocationPermission = fine || coarse
            isGpsEnabled = checkGpsEnabled()
        }
    )

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                hasLocationPermission = checkLocationPermissions()
                isGpsEnabled = checkGpsEnabled()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    LaunchedEffect(Unit) {
        if (!hasLocationPermission) {
            permissionLauncher.launch(
                arrayOf(
                    Manifest.permission.ACCESS_FINE_LOCATION,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                )
            )
        }
    }

    if (hasLocationPermission && isGpsEnabled) {
        PokeMapScreen()
    } else {
        LocationPermissionDeniedScreen(
            hasPermission = hasLocationPermission,
            isGpsEnabled = isGpsEnabled,
            onRequestPermission = {
                permissionLauncher.launch(
                    arrayOf(
                        Manifest.permission.ACCESS_FINE_LOCATION,
                        Manifest.permission.ACCESS_COARSE_LOCATION
                    )
                )
            },
            onOpenAppSettings = {
                val intent = Intent(
                    Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                    Uri.fromParts("package", context.packageName, null)
                )
                context.startActivity(intent)
            },
            onOpenLocationSettings = {
                val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
                context.startActivity(intent)
            }
        )
    }
}

@Composable
fun LocationPermissionDeniedScreen(
    hasPermission: Boolean,
    isGpsEnabled: Boolean,
    onRequestPermission: () -> Unit,
    onOpenAppSettings: () -> Unit,
    onOpenLocationSettings: () -> 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 = if (!hasPermission) "📍" else "🛰️",
                    fontSize = 48.sp
                )
                Spacer(modifier = Modifier.height(12.dp))
                Text(
                    text = if (!hasPermission) "Permissão de GPS Necessária" else "GPS do Dispositivo Desligado",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color(0xFF00E676),
                    textAlign = TextAlign.Center
                )
                Spacer(modifier = Modifier.height(12.dp))
                Text(
                    text = if (!hasPermission) {
                        "Para explorar os Ginásios Pokémon na sua cidade e plotar sua localização em tempo real no mapa, o aplicativo precisa de acesso ao GPS."
                    } else {
                        "O serviço de localização (GPS) do seu smartphone está desativado. Ative a localização para renderizar o mapa."
                    },
                    fontSize = 14.sp,
                    color = Color.White.copy(alpha = 0.8f),
                    textAlign = TextAlign.Center,
                    lineHeight = 20.sp
                )
                Spacer(modifier = Modifier.height(24.dp))

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

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

                    OutlinedButton(
                        onClick = onOpenAppSettings,
                        modifier = Modifier.fillMaxWidth(),
                        shape = RoundedCornerShape(12.dp),
                        colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF00B0FF))
                    ) {
                        Text("ABRIR CONFIGURAÇÕES DO APP")
                    }
                } else {
                    Button(
                        onClick = onOpenLocationSettings,
                        modifier = Modifier.fillMaxWidth(),
                        colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00B0FF)),
                        shape = RoundedCornerShape(12.dp)
                    ) {
                        Text(
                            "LIGAR GPS NAS CONFIGURAÇÕES",
                            color = Color.Black,
                            fontWeight = FontWeight.Bold
                        )
                    }
                }

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

                Text(
                    text = "💡 Dica: Ao alterar as permissões ou ligar o GPS nas configurações do Android, basta voltar ao app que ele abrirá o mapa automaticamente!",
                    fontSize = 12.sp,
                    color = Color.Gray,
                    textAlign = TextAlign.Center,
                    lineHeight = 16.sp
                )
            }
        }
    }
}

@Composable
fun PokeMapScreen() {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    val defaultLocation = LatLng(-23.5505, -46.6333)
    var userLocation by remember { mutableStateOf<LatLng?>(null) }
    var selectedGym by remember { mutableStateOf<PokemonGym?>(null) }

    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(defaultLocation, 15f)
    }

    val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }

    LaunchedEffect(Unit) {
        try {
            if (ContextCompat.checkSelfPermission(
                    context,
                    Manifest.permission.ACCESS_FINE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED ||
                ContextCompat.checkSelfPermission(
                    context,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
            ) {
                fusedLocationClient.lastLocation.addOnSuccessListener { loc ->
                    if (loc != null) {
                        val currentLatLng = LatLng(loc.latitude, loc.longitude)
                        userLocation = currentLatLng
                        coroutineScope.launch {
                            cameraPositionState.animate(
                                CameraUpdateFactory.newCameraPosition(
                                    CameraPosition.fromLatLngZoom(currentLatLng, 16f)
                                )
                            )
                        }
                    }
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    val basePos = userLocation ?: defaultLocation
    val gymList = remember(basePos) {
        listOf(
            PokemonGym(
                id = "gym_1",
                name = "Ginásio Central",
                leader = "Brock",
                type = "ROCHA",
                badge = "Insígnia da Rocha",
                position = LatLng(basePos.latitude + 0.0020, basePos.longitude + 0.0025)
            ),
            PokemonGym(
                id = "gym_2",
                name = "Ginásio Aquático",
                leader = "Misty",
                type = "ÁGUA",
                badge = "Insígnia da Cascata",
                position = LatLng(basePos.latitude - 0.0025, basePos.longitude - 0.0020)
            ),
            PokemonGym(
                id = "gym_3",
                name = "Ginásio Elétrico",
                leader = "Lt. Surge",
                type = "ELÉTRICO",
                badge = "Insígnia do Trovão",
                position = LatLng(basePos.latitude + 0.0035, basePos.longitude - 0.0030)
            )
        )
    }

    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState,
            properties = MapProperties(
                isMyLocationEnabled = true,
                mapType = MapType.NORMAL
            ),
            uiSettings = MapUiSettings(
                myLocationButtonEnabled = true,
                zoomControlsEnabled = false,
                compassEnabled = true
            )
        ) {
            gymList.forEach { gym ->
                Marker(
                    state = MarkerState(position = gym.position),
                    title = gym.name,
                    snippet = "Líder: ${gym.leader} | Tipo: ${gym.type}",
                    onClick = {
                        selectedGym = gym
                        false
                    }
                )
            }
        }

        Card(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
                .fillMaxWidth(),
            colors = CardDefaults.cardColors(containerColor = Color(0xFF121212).copy(alpha = 0.9f)),
            shape = RoundedCornerShape(14.dp)
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp, vertical = 12.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Column {
                    Text(
                        text = "🗺️ POKÉMAP: GPS ATIVO",
                        fontWeight = FontWeight.Black,
                        fontSize = 14.sp,
                        color = Color(0xFF00E676)
                    )
                    Text(
                        text = "Toque em um ginásio para desafiar",
                        fontSize = 11.sp,
                        color = Color.LightGray
                    )
                }
                Text(
                    text = "${gymList.size} Ginásios",
                    fontWeight = FontWeight.Bold,
                    fontSize = 12.sp,
                    color = Color(0xFF00B0FF)
                )
            }
        }

        selectedGym?.let { gym ->
            Card(
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(16.dp)
                    .fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF181818)),
                shape = RoundedCornerShape(20.dp),
                elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
            ) {
                Column(modifier = Modifier.padding(20.dp)) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(
                            text = gym.name,
                            fontWeight = FontWeight.Black,
                            fontSize = 20.sp,
                            color = Color(0xFF00E676)
                        )
                        Surface(
                            shape = RoundedCornerShape(8.dp),
                            color = Color(0xFF00B0FF).copy(alpha = 0.2f)
                        ) {
                            Text(
                                text = gym.type,
                                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
                                fontSize = 11.sp,
                                fontWeight = FontWeight.Bold,
                                color = Color(0xFF00B0FF)
                            )
                        }
                    }

                    Spacer(modifier = Modifier.height(6.dp))
                    Text(
                        text = "Líder de Ginásio: ${gym.leader}", fontSize = 14.sp, color = Color.White, fontWeight = FontWeight.SemiBold ) Text( text = "Recompensa de Vitória:${gym.badge}",
                        fontSize = 12.sp,
                        color = Color.Gray
                    )

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

                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(10.dp)
                    ) {
                        OutlinedButton(
                            onClick = { selectedGym = null },
                            modifier = Modifier.weight(1f),
                            shape = RoundedCornerShape(12.dp),
                            colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.LightGray)
                        ) {
                            Text("FECHAR")
                        }

                        Button(
                            onClick = {
                                Toast.makeText(
                                    context,
                                    "⚔️ Batalha contra ${gym.leader} iniciada no ${gym.name}!",
                                    Toast.LENGTH_SHORT
                                ).show()
                                selectedGym = null
                            },
                            modifier = Modifier.weight(1.5f),
                            colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676)),
                            shape = RoundedCornerShape(12.dp)
                        ) {
                            Text("DESAFIAR GINÁSIO", color = Color.Black, fontWeight = FontWeight.Black)
                        }
                    }
                }
            }
        }
    }
}

🚀 Como Executar no Laboratório

1. Abra o terminal na pasta deste projeto

No seu editor/IDE, abra a pasta deste projeto (File > Open Folder) ou navegue via terminal:

cd proj_aplicacoes_full_stack/projetos/android_p09_pokemap_gps

2. Execute a aplicação e os testes

./gradlew build
./gradlew test
# ou abrir no Android Studio e clicar em Run (Shift+F10)

[!TIP] Dica para execução a partir da raiz do repositório: Se você abriu o repositório completo no VS Code ou Android Studio, abra o projeto diretamente pela pasta proj_aplicacoes_full_stack/projetos/android_p09_pokemap_gps.