📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Android Nativo. Recomendado revisar antes de começar.
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)!
| 1. Permissão & Fallback GPS | 2. Mapa Ativo com Ponto Azul | 3. Desafio de Ginásio (Card) |
|---|---|---|
| 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. |
Antes de iniciar este laboratório, certifique-se de ter compreendido:
Ao concluir este projeto autoguiado, você será capaz de:
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.LocationManager e guiar o usuário com um toque para ACTION_LOCATION_SOURCE_SETTINGS.FusedLocationProviderClient): Capturar as coordenadas de latitude/longitude reais do aparelho e animar a câmera do mapa (CameraUpdateFactory.newLatLngZoom) suavemente.maps-compose (GoogleMap, Marker, MarkerState, MapProperties, MapUiSettings).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
[!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/
- Acesse o link acima no navegador.
- Clique em “Get Demo Key” (Obter Chave de Demonstração).
- Copie a chave de API gerada.
.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.envoulocal.propertiesna 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!
android_p09_pokemap_gps/, crie o arquivo .env (ou use o local.properties):
MAPS_API_KEY=AIzaSy_SUA_CHAVE_DEMO_AQUI
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}" />
| 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. |
File > New > New Project).PokeMap (ou android_p09_pokemap_gps)br.com.curso.pokemapKotlinAPI 24 ("Nougat"; Android 7.0) ou superiorGroovy DSL (build.gradle) ou Kotlin DSL (build.gradle.kts)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>
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'
}
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)
)
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
)
}
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:
- Solicitar simultaneamente
ACCESS_FINE_LOCATIONeACCESS_COARSE_LOCATIONviaRequestMultiplePermissions().- Oferecer o atalho para as Configurações do Aplicativo (
Settings.ACTION_APPLICATION_DETAILS_SETTINGS).- Verificar se o serviço de GPS está ligado no aparelho e oferecer atalho para
Settings.ACTION_LOCATION_SOURCE_SETTINGS.- Registrar um
LifecycleEventObserverque monitoraON_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)
}
)
}
}
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...
}
}
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)
}
}
}
}
}
| 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. |
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 |
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!MapType.NORMAL, MapType.SATELLITE e MapType.HYBRID.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)
}
}
}
}
}
}
}
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
./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.