📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Android Nativo. Recomendado revisar antes de começar.
Bem-vindo à segunda missão da Fase 4: Recursos Avançados! ☁️
Este é o projeto que separa oficialmente os desenvolvedores iniciantes dos profissionais de mercado! Você aprenderá a conectar seu aplicativo de forma dinâmica à internet para consumir serviços web (APIs REST). Mapearemos dados estruturados da famosa PokeAPI usando a biblioteca Retrofit combinada com a renderização de imagens externas obtidas diretamente do servidor através do carregador assíncrono inteligente Coil.
Antes de começar, certifique-se de já ter estudado:
Criar uma Pokédex virtual completa e dinâmica disposta em duas colunas, contendo:
CircularProgressIndicator girando) ou mensagens de erro robustas caso a conexão do celular falhe.LazyVerticalGrid).AsyncImage.graph TD
A[Início / Inicializar PokedexApp] --> B[LaunchedEffect dispara requisição em background]
B --> C[Exibir CircularProgressIndicator]
C --> D[Retrofit GET pokemon?limit=20]
D --> E{Requisição com Sucesso?}
E -- Não --> F[Salvar erro no estado & Exibir Mensagem de Erro]
E -- Sim --> G[Mapear lista de Pokemons para pokemonList]
G --> H[Ocultar loading & Renderizar LazyVerticalGrid]
H --> I[Para cada item: PokemonCard]
I --> J[Extrair ID da URL & Calcular URL da Imagem no GitHub]
J --> K[Carregar Imagem via Coil AsyncImage & Exibir Nome]
equals(), hashCode() e toString(), poupando código repetitivo.Master Pokedexbr.com.curso.masterpokedexAntes de fazer conexões de rede, o Android exige que o app declare explicitamente que usará a antena de dados/Wi-Fi do aparelho. Sem essa declaração, qualquer chamada de rede lançará uma SecurityException e o app fechará.
app > src > main e abra o arquivo AndroidManifest.xml.<application>, deixando o arquivo assim:<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="Master Pokedex"
android:theme="@style/Theme.MasterPokedex">
...
</application>
</manifest>
Por que acima de
<application>? A tag<uses-permission>deve ficar no nível do<manifest>, não dentro da<application>. Colocá-la no lugar errado gera um warning de build e pode não funcionar em versões antigas do Android.
As bibliotecas Retrofit e Coil não vêm com o Android Studio — precisamos baixá-las pelo sistema de build Gradle.
Gradle Scripts e abra o arquivo build.gradle (Module :app) (não o do projeto raiz).dependencies { ... } e adicione as três novas linhas abaixo das dependências já existentes:dependencies {
// ... dependências que já existem (compose-bom, material3, activity-compose, etc.) ...
// Retrofit: cliente HTTP para fazer chamadas à API
implementation "com.squareup.retrofit2:retrofit:2.9.0"
// Converter GSON: traduz o JSON da resposta em classes Kotlin
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
// Coil: carregador assíncrono de imagens para Jetpack Compose
implementation "io.coil-kt:coil-compose:2.6.0"
}
Como verificar se o sync funcionou? Após o sync, você deve conseguir importar
import retrofit2.Retrofitem um arquivo.ktsem erro de símbolo não encontrado.
PokeApi.kt)Antes de fazer chamadas de rede, precisamos criar um mapa do tesouro — estruturas de dados que descrevem exatamente o formato JSON que a PokeAPI devolve. O Retrofit usa esse mapa para traduzir automaticamente o JSON em objetos Kotlin.
Abra seu navegador e acesse: https://pokeapi.co/api/v2/pokemon?limit=20
Você verá um JSON parecido com este:
{
"count": 1302,
"results": [
{ "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1/" },
{ "name": "ivysaur", "url": "https://pokeapi.co/api/v2/pokemon/2/" },
...
]
}
Precisamos criar data classes Kotlin que espelhem exatamente esse formato:
results que é uma lista.name e uma url.app > java > br.com.curso.masterpokedex.data.network. Pressione Enter.network.PokeApi. Pressione Enter.Cole o seguinte código no arquivo PokeApi.kt recém-criado:
package br.com.curso.masterpokedex.data.network
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Path
// ─── PARTE 1: MODELOS DE DADOS (o "mapa" do JSON) ───────────────────────────
// Representa o objeto raiz retornado pela API na listagem
data class PokemonListResponse(
val results: List<PokemonBrief> // campo "results" do JSON
)
// Representa cada item da lista (nome + URL com o ID embutido)
data class PokemonBrief(
val name: String, // ex: "bulbasaur"
val url: String // ex: "https://pokeapi.co/api/v2/pokemon/1/"
)
// Modelos usados no Desafio: buscar detalhes de um Pokémon específico
data class PokemonDetailResponse(
val id: Int,
val name: String,
@SerializedName("sprites") val sprites: SpriteResponse // "sprites" no JSON → sprites em Kotlin
)
data class SpriteResponse(
@SerializedName("front_default") val frontDefault: String // "front_default" no JSON → frontDefault
)
// ─── PARTE 2: O GARÇOM (interface de requisições) ────────────────────────────
// O Retrofit usa essa interface para gerar automaticamente o código de rede
interface PokeApiService {
// GET /pokemon?limit=20 → retorna a lista dos 20 primeiros
@GET("pokemon?limit=20")
suspend fun getList(): PokemonListResponse
// GET /pokemon/{name} → retorna detalhes de um Pokémon pelo nome
@GET("pokemon/{name}")
suspend fun getDetail(@Path("name") name: String): PokemonDetailResponse
}
Explicação das anotações:
@GET("pokemon?limit=20") — instrui o Retrofit a fazer uma requisição HTTP GET para o endpoint pokemon?limit=20 (relativo à baseUrl que configuraremos no ViewModel).suspend fun — garante que a chamada de rede rode em uma coroutine, sem bloquear a Thread da UI.@SerializedName("front_default") — necessário quando o nome do campo no JSON usa snake_case, diferente do padrão camelCase do Kotlin. O GSON usa essa anotação para fazer o mapeamento correto.@Path("name") — substitui o trecho {name} na URL pelo valor passado no parâmetro da função.PokedexViewModel)O ViewModel é o gerente de dados da tela. Ele inicializa o cliente Retrofit, expõe o serviço de API e mantém três variáveis de estado que a interface observa em tempo real.
Abra o arquivo app > java > br.com.curso.masterpokedex > MainActivity.kt e substitua todo o conteúdo pelos blocos abaixo (começando pelo ViewModel, antes da classe MainActivity).
No topo do MainActivity.kt, certifique-se de ter todos estes imports:
package br.com.curso.masterpokedex
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
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.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import br.com.curso.masterpokedex.data.network.PokeApiService
import br.com.curso.masterpokedex.data.network.PokemonBrief
import coil.compose.AsyncImage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
Novidade:
ColorPainteralimenta oplaceholder/errordoAsyncImage(Passo 6.3), eCancellationException/launchsão usados peloPokedexViewModel(Passo 5.2) e pelo botão “Tentar novamente” (Passo 6.2).
class PokedexViewModel : ViewModel() {
// Instância do Retrofit com baseUrl e conversor GSON
private val retrofit = Retrofit.Builder()
.baseUrl("https://pokeapi.co/api/v2/") // endereço base da PokeAPI
.addConverterFactory(GsonConverterFactory.create()) // plugin de tradução JSON→Kotlin
.build()
// Cria o "garçom" a partir da interface que definimos em PokeApi.kt
val apiService: PokeApiService = retrofit.create(PokeApiService::class.java)
// ── Os três estados reativos que a UI observa ──────────────────────────
var pokemonList by mutableStateOf<List<PokemonBrief>>(emptyList())
// ↑ lista preenchida após resposta bem-sucedida
var isLoading by mutableStateOf(false)
// ↑ true enquanto aguarda resposta da rede
var error by mutableStateOf<String?>(null)
// ↑ mensagem de erro caso a chamada falhe; null quando tudo está ok
// Busca a lista de Pokémons. Extraída em uma função própria (em vez de ficar
// direto no LaunchedEffect) para poder ser chamada de novo pelo botão "Tentar novamente".
suspend fun carregarPokemons() {
isLoading = true
error = null
try {
val response = apiService.getList()
pokemonList = response.results
} catch (e: CancellationException) {
throw e // ↑ IMPORTANTE: nunca capturar o cancelamento da coroutine como se fosse erro
} catch (e: Exception) {
error = "Não foi possível carregar os Pokémons. Verifique sua conexão."
} finally {
isLoading = false
}
}
}
Por que mutableStateOf e não LiveData? No Jetpack Compose, mutableStateOf é a forma idiomática de criar estados que acionam recomposição automática da UI quando seus valores mudam — exatamente como um placar de jogo que atualiza o número na tela ao marcar um ponto.
Por que capturar CancellationException separadamente? O Kotlin usa uma exceção especial para cancelar coroutines (por exemplo, quando o usuário sai da tela no meio de uma requisição). Um catch (e: Exception) genérico também captura essa exceção sem querer, transformando um cancelamento normal em uma mensagem de “erro” indevida. Por isso o catch de CancellationException vem primeiro e sempre relança (throw e).
MainActivity.kt)Com o ViewModel pronto, construímos a interface visual em três partes: o Tema, a Tela Principal (PokedexApp) e os Cartões de Pokémon (PokemonCard).
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MasterPokedexTheme { // aplica as cores da Pokédex em todo o app
PokedexApp()
}
}
}
}
/**
* Tema customizado com paleta Pokédex (vermelho + verde neon)
*/
@Composable
fun MasterPokedexTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFFFF1744), // vermelho Pokédex
secondary = Color(0xFF00E676), // verde neon
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E)
),
content = content
)
}
A tela faz a requisição na primeira vez que é exibida (via LaunchedEffect) e usa um bloco when para decidir o que mostrar dependendo do estado atual.
/**
* Tela principal da Pokédex com gerenciamento de estados de rede e grade de cartões.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PokedexApp(viewModel: PokedexViewModel = viewModel()) {
// CoroutineScope ligado ao ciclo de vida da composição — usado para o clique
// do botão "Tentar novamente" disparar uma nova busca fora do LaunchedEffect
val coroutineScope = rememberCoroutineScope()
// LaunchedEffect(Unit): executa o bloco UMA ÚNICA VEZ ao entrar na tela
LaunchedEffect(Unit) {
viewModel.carregarPokemons() // toda a lógica de loading/try-catch mora no ViewModel (5.2)
}
// Gradiente vermelho escuro → preto como fundo da tela
val backgroundGradient = Brush.verticalGradient(
colors = listOf(Color(0xFF8E0E00), Color(0xFF1F1C1C))
)
Box(modifier = Modifier.fillMaxSize().background(backgroundGradient)) {
Scaffold(
containerColor = Color.Transparent, // deixa o gradiente aparecer atrás
topBar = {
TopAppBar(
title = {
Text(
"MASTER POKÉDEX",
fontWeight = FontWeight.Black,
letterSpacing = 2.sp,
color = Color.White
)
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
when {
// Estado 1 — Carregando: exibe o anel giratório centralizado
viewModel.isLoading -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color(0xFFFF1744)
)
// Estado 2 — Erro de rede: exibe a mensagem e um botão para tentar de novo
viewModel.error != null -> Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Erro: ${viewModel.error}",
color = Color.White,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { coroutineScope.launch { viewModel.carregarPokemons() } }) {
Text("Tentar novamente")
}
}
// Estado 3 — Sucesso: exibe a grade de cartões
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2), // sempre 2 colunas
contentPadding = PaddingValues(8.dp) // espaço nas bordas da grade
) {
items(viewModel.pokemonList) { pokemon ->
PokemonCard(pokemon)
}
}
}
}
}
}
}
Por que when em vez de if/else? O when em Kotlin é mais legível para múltiplas condições e deixa claro que apenas um dos três estados pode estar ativo por vez — uma analogia com os estados de uma batalha Pokémon: ou está carregando, ou houve erro, ou os dados estão disponíveis.
Por que o botão “Tentar novamente” chama coroutineScope.launch { ... }? onClick é uma função comum (não suspend), então não pode chamar viewModel.carregarPokemons() diretamente. O rememberCoroutineScope() cria um escopo de coroutine amarrado à tela, permitindo iniciar a busca de novo a partir de um clique — sem essa opção, uma falha de rede (ex: avião, Wi-Fi instável) deixaria o usuário travado na tela de erro até fechar e reabrir o app.
O PokemonCard recebe um PokemonBrief (nome + URL), extrai o ID numérico da URL e monta o endereço da arte oficial hospedada no repositório do PokeAPI no GitHub.
/**
* Cartão individual de Pokémon com imagem oficial e número formatado
*/
@Composable
fun PokemonCard(pokemon: PokemonBrief) {
// A URL tem o formato: "https://pokeapi.co/api/v2/pokemon/25/"
// trimEnd('/') remove a barra final → "https://pokeapi.co/api/v2/pokemon/25"
// substringAfterLast('/') pega o último trecho → "25"
// toIntOrNull() converte para Int, ou null se o formato vier diferente do esperado
val id = pokemon.url.trimEnd('/').substringAfterLast('/').toIntOrNull()
// Arte oficial em PNG hospedada no GitHub do projeto PokeAPI
// Se id for null, imageUrl também fica null — o AsyncImage cai no estado de erro
// (bloco abaixo) em vez de montar uma URL quebrada com "null.png"
val imageUrl = id?.let {
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$it.png"
}
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.05f)),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// AsyncImage (Coil): baixa e exibe a imagem da nuvem de forma assíncrona
// placeholder = enquanto carrega | error = se falhar (ou se model for null)
// Sem esses dois parâmetros, uma falha de rede deixaria um espaço em branco sem feedback nenhum
AsyncImage(
model = imageUrl,
contentDescription = pokemon.name,
placeholder = ColorPainter(Color.White.copy(alpha = 0.1f)),
error = ColorPainter(Color.White.copy(alpha = 0.1f)),
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = pokemon.name.uppercase(), // "bulbasaur" → "BULBASAUR"
fontWeight = FontWeight.Bold,
color = Color.White,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Text(
// 1 → "#001", 25 → "#025"; se o id não pôde ser extraído, mostra "#???" em vez de mentir "#001"
text = id?.let { "#${it.toString().padStart(3, '0')}" } ?: "#???",
color = Color.Gray,
fontSize = 12.sp
)
}
}
}
Como padStart(3, '0') funciona? Garante que o número sempre ocupe 3 dígitos, preenchendo com zeros à esquerda se necessário. Pikachu é o #025, não o #25 — esse é o padrão oficial da Pokédex Nacional.
Por que toIntOrNull() em vez de um ?: "1" fixo? Um fallback fixo (ex: assumir sempre "1" quando a extração falha) faz o card mostrar dados errados silenciosamente — a imagem e o número do Bulbasaur em um card que não é do Bulbasaur — sem nenhum aviso de que algo deu errado. Com toIntOrNull(), um formato de URL inesperado vira id == null, e o card mostra "#???" e o placeholder de erro da imagem: o problema fica visível em vez de mascarado.
Por padrão, o Android bloqueia conexões inseguras (protocolo HTTP simples). Certifique-se de que a baseUrl no ViewModel use https:// obrigatoriamente:
.baseUrl("https://pokeapi.co/api/v2/") // ✅ correto
.baseUrl("http://pokeapi.co/api/v2/") // ❌ bloqueado pelo Android
Sempre envolva chamadas de rede em blocos try-catch. Sem essa proteção, qualquer falha de rede (modo avião, sem Wi-Fi, servidor indisponível) causaria um crash imediato do app. E capture CancellationException separadamente antes do catch genérico — senão um cancelamento normal de coroutine (ex: usuário saiu da tela) é tratado como se fosse um erro de rede:
try {
val response = apiService.getList()
pokemonList = response.results
} catch (e: CancellationException) {
throw e // cancelamento de coroutine: sempre relançar, nunca tratar como erro
} catch (e: Exception) {
error = "Não foi possível carregar os Pokémons. Verifique sua conexão." // mensagem amigável, sem crash
} finally {
isLoading = false // garante que o loading sempre pare
}
baseUrl deve terminar com /O Retrofit exige que a URL base sempre termine com barra. Caso contrário, ele lançará uma IllegalArgumentException na inicialização do app.
Se você terminou de programar a funcionalidade base e tudo está funcionando, experimente estes upgrades:
getDetail(), que já está definido em PokeApiService, e adicione Navigation Compose ao projeto.OutlinedTextField no topo da tela e filtre viewModel.pokemonList pelo nome digitado usando .filter { it.name.contains(query, ignoreCase = true) }.Use os códigos Kotlin completos abaixo para validar sua implementação.
PokeApi.kt)Disponível em: app/src/main/java/br/com/curso/masterpokedex/data/network/PokeApi.kt
package br.com.curso.masterpokedex.data.network
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Path
// 1. MODELOS DE DADOS (Dicionário da API)
data class PokemonListResponse(
val results: List<PokemonBrief>
)
data class PokemonBrief(
val name: String,
val url: String
)
data class PokemonDetailResponse(
val id: Int,
val name: String,
@SerializedName("sprites") val sprites: SpriteResponse
)
data class SpriteResponse(
@SerializedName("front_default") val frontDefault: String
)
// 2. O GARÇOM (Retrofit Interface)
interface PokeApiService {
@GET("pokemon?limit=20")
suspend fun getList(): PokemonListResponse
@GET("pokemon/{name}")
suspend fun getDetail(@Path("name") name: String): PokemonDetailResponse
}
MainActivity.kt)Disponível em: app/src/main/java/br/com/curso/masterpokedex/MainActivity.kt
package br.com.curso.masterpokedex
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
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.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import br.com.curso.masterpokedex.data.network.PokeApiService
import br.com.curso.masterpokedex.data.network.PokemonBrief
import coil.compose.AsyncImage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MasterPokedexTheme {
PokedexApp()
}
}
}
}
/**
* Tema customizado com paleta Pokédex (vermelho + verde neon)
*/
@Composable
fun MasterPokedexTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFFFF1744), // Vermelho Pokedex
secondary = Color(0xFF00E676),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E)
),
content = content
)
}
class PokedexViewModel : ViewModel() {
// Instância do Retrofit com baseUrl e conversor GSON
private val retrofit = Retrofit.Builder()
.baseUrl("https://pokeapi.co/api/v2/")
.addConverterFactory(GsonConverterFactory.create())
.build()
// Expõe o service para ser chamado pela UI
val apiService: PokeApiService = retrofit.create(PokeApiService::class.java)
// Estados reativos que a UI observa
var pokemonList by mutableStateOf<List<PokemonBrief>>(emptyList())
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
// Busca a lista de Pokémons; chamada pelo LaunchedEffect e pelo botão "Tentar novamente"
suspend fun carregarPokemons() {
isLoading = true
error = null
try {
val response = apiService.getList()
pokemonList = response.results
} catch (e: CancellationException) {
throw e // nunca engolir cancelamento de coroutine
} catch (e: Exception) {
error = "Não foi possível carregar os Pokémons. Verifique sua conexão."
} finally {
isLoading = false
}
}
}
/**
* Tela principal da Pokédex com gerenciamento de estados de rede e grade de cartões.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PokedexApp(viewModel: PokedexViewModel = viewModel()) {
val coroutineScope = rememberCoroutineScope()
// Executa a requisição uma única vez ao entrar na tela
LaunchedEffect(Unit) {
viewModel.carregarPokemons()
}
// Gradiente vermelho escuro → preto
val backgroundGradient = Brush.verticalGradient(
colors = listOf(Color(0xFF8E0E00), Color(0xFF1F1C1C))
)
Box(modifier = Modifier.fillMaxSize().background(backgroundGradient)) {
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
"MASTER POKÉDEX",
fontWeight = FontWeight.Black,
letterSpacing = 2.sp,
color = Color.White
)
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
when {
// Estado: carregando
viewModel.isLoading -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color(0xFFFF1744)
)
// Estado: erro de rede
viewModel.error != null -> Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Erro: ${viewModel.error}",
color = Color.White,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { coroutineScope.launch { viewModel.carregarPokemons() } }) {
Text("Tentar novamente")
}
}
// Estado: lista carregada
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp)
) {
items(viewModel.pokemonList) { pokemon ->
PokemonCard(pokemon)
}
}
}
}
}
}
}
/**
* Cartão individual de Pokémon com imagem oficial e número formatado
*/
@Composable
fun PokemonCard(pokemon: PokemonBrief) {
// Extrai o ID numérico da URL: "https://pokeapi.co/api/v2/pokemon/1/" -> 1
// Se a URL vier em formato inesperado, id fica null em vez de assumir "1" na cara-dura
val id = pokemon.url.trimEnd('/').substringAfterLast('/').toIntOrNull()
val imageUrl = id?.let {
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$it.png"
}
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.05f)),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Carrega a arte oficial diretamente da nuvem (Coil gerencia cache)
// placeholder/error evitam um espaço em branco caso a imagem falhe ou o id seja nulo
AsyncImage(
model = imageUrl,
contentDescription = pokemon.name,
placeholder = ColorPainter(Color.White.copy(alpha = 0.1f)),
error = ColorPainter(Color.White.copy(alpha = 0.1f)),
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = pokemon.name.uppercase(),
fontWeight = FontWeight.Bold,
color = Color.White,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Text(
text = id?.let { "#${it.toString().padStart(3, '0')}" } ?: "#???",
color = Color.Gray,
fontSize = 12.sp
)
}
}
}
—`
No seu editor/IDE, abra a pasta deste projeto (File > Open Folder) ou navegue via terminal:
cd android_p07_pokedex_retrofit
./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, basta navegar até a pasta antes de executar: cd proj_aplicacoes_full_stack/projetos/android_p07_pokedex_retrofit