📍 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:
- 📘 Cap 17: PokeMap: Onde estão os Treinadores?
- 🏗️ Projeto anterior: P08: Batalha Scanner
🎯 Objetivo do Projeto
Criar um mapa dinâmico em tela cheia renderizando o terreno urbano da sua cidade em tempo real.
- Posicionamento por Satélite (GPS): Solicitar permissão de localização precisa (
ACCESS_FINE_LOCATION) e renderizar a bolinha azul indicadora da posição real do jogador. - Checkpoint de Ginásios: Mapear múltiplos alfinetes (
Markerdo Google Maps) representando diferentes ginásios de líderes como Brock (Rocha) e Misty (Água). - Painel de Desafio Deslizante: Interceptar o clique do usuário em qualquer ginásio e exibir um painel inferior (
Cardoverlay) com informações adicionais e botões de ação (“CANCELAR” ou “BATALHAR”), permitindo iniciar disputas.
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
- Google Maps SDK for Android: A biblioteca de serviços de mapas do Google que renderiza mapas vetoriais, terrenos, trânsito e imagens de satélite dentro de aplicações nativas.
- maps-compose: O kit oficial desenvolvido pelo Google que traduz o SDK de mapas clássico em pacotes totalmente reativos e declarativos compatíveis com o Jetpack Compose.
- LatLng: O objeto geográfico que armazena a localização exata de um ponto terrestre através de um par ordenado de Latitude (norte/sul) e Longitude (leste/oeste).
- rememberCameraPositionState: O estado reativo que armazena a inclinação, rotação, nível de zoom e a coordenada geográfica na qual o centro do visor do mapa está focado.
- Marker / MarkerState: O alfinete físico desenhado pelo motor gráfico do mapa para sinalizar um ponto turístico, endereço ou ginásio cadastrado.
- MapProperties (isMyLocationEnabled): Propriedade que ativa a leitura nativa dos satélites do celular para exibir o famoso ponto azul na posição exata em que o celular se encontra fisicamente.
- MapUiSettings: Configurações que habilitam botões de controle de zoom clássicos, bússola ou o botão nativo para focar a câmera de volta no local do usuário.
🛠️ 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.
- Acesse o Google Cloud Console.
- Crie ou selecione um projeto e acesse o menu APIs e Serviços.
- Pesquise por Maps SDK for Android e clique em Ativar.
- Vá em Credenciais > Criar Credenciais > Chave de API e copie a sua chave gerada.
🛠️ Passo 2: Configurando Permissões e Chave de API (Manifesto)
- Abra o arquivo
app > src > main > AndroidManifest.xml. - 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
- Abra
Gradle Scripts > build.gradle (Module :app). - 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)
}
}
}
}
}
}
}