☁️ P07: Master Pokedex (Conectando à Nuvem com Retrofit)

Bem-vindo à segunda missão da Fase 4: Persistência & Dados (Offline First & APIs)! ☁️

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.


✅ Pré-requisitos

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


🎯 Objetivo do Projeto

Criar uma Pokédex virtual completa e dinâmica disposta em duas colunas, contendo:

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]

📖 Dicionário do Projeto


🛠️ Passo 1: Solicitando Acesso à Internet (Manifesto)

Antes de fazer conexões de rede, o Android exige que o app declare que usará a antena de dados/Wi-Fi do aparelho.

  1. Abra o arquivo app > src > main > AndroidManifest.xml.
  2. Adicione a linha de permissão logo acima da tag <application>: ```xml

---

## 🛠️ Passo 2: Adicionando Dependências do Retrofit e Coil (Gradle)

1.  Abra `Gradle Scripts > build.gradle (Module :app)`.
2.  No bloco `dependencies`, adicione a biblioteca Retrofit, o conversor de JSON GSON e o Coil para imagens:
```gradle
dependencies {
    // Retrofit & GSON Converter
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"

    // Coil para carregamento assíncrono de imagens no Compose
    implementation "io.coil-kt:coil-compose:2.5.0"
    ...
}

Clique em Sync Now.


☁️ Passo 3: Mapeando os Modelos de Dados (PokeApi.kt)

Criaremos a estrutura de rede que representa a resposta do servidor em formato fortemente tipado.

  1. Crie a pasta de pacotes: app > java > br.com.curso.masterpokedex > data > network.
  2. Crie o arquivo PokeApi.kt e cole a estrutura completa disponível no Gabarito. Ele mapeia a resposta da lista da API e configura os comandos do garçom (PokeApiService) usando requisições assíncronas do tipo GET.

🧠 Passo 4: Construindo o ViewModel (PokedexViewModel)

O ViewModel centraliza a base URL da PokeAPI (https://pokeapi.co/api/v2/) e inicializa a interface do Retrofit com o tradutor GSON. Ele também monitora as três variáveis chaves de estado de rede: pokemonList (sucesso), isLoading (carregamento) e error (falha).


🎨 Passo 5: Criando a Grade de Cartões e Imagens Asíncronas

Em nossa MainActivity.kt, desenhamos o app usando Scaffold.

Dentro do conteúdo principal, usamos a variável isLoading para exibir um sinalizador de progresso ou a nossa grade LazyVerticalGrid de duas colunas (GridCells.Fixed(2)). Cada cartão renderiza o nome e o número padronizado do Pokémon, enquanto o componente AsyncImage baixa a arte oficial da nuvem de forma invisível.


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

1. URLs HTTPS Obrigatórias

Por padrão, o Android bloqueia conexões inseguras (que usam o protocolo HTTP simples). Certifique-se de que a sua baseUrl em seu ViewModel use https:// obrigatoriamente.

2. Tratamento de Erros

Sempre faça requisições externas envelopadas em blocos try-catch. Caso o celular do usuário fique sem sinal ou em modo avião, a falha de rede gerará uma exceção que fecharia o app (crash) instantaneamente caso não estivesse capturada com segurança.


🏆 Desafios para você (Upgrade!)

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


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

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

📄 Código de Serviços Web Completo (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
}

📄 Código de Lógica e Tela Completa (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.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.launch
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

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

@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() {
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://pokeapi.co/api/v2/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    val apiService: PokeApiService = retrofit.create(PokeApiService::class.java)

    var pokemonList by mutableStateOf<List<PokemonBrief>>(emptyList())
    var isLoading by mutableStateOf(false)
    var error by mutableStateOf<String?>(null)

    fun fetchPokemons() {
        isLoading = true
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PokedexApp(viewModel: PokedexViewModel = viewModel()) {
    val coroutineScope = rememberCoroutineScope()
    
    // Carregar os dados ao iniciar
    LaunchedEffect(Unit) {
        viewModel.isLoading = true
        try {
            val response = viewModel.apiService.getList()
            viewModel.pokemonList = response.results
        } catch (e: Exception) {
            viewModel.error = e.message
        } finally {
            viewModel.isLoading = false
        }
    }

    val backgroundGradient = Brush.verticalGradient(
        colors = listOf(Color(0xFF8E0E00), Color(0xFF1F1C1C)) // Gradiente vermelho escuro/preto
    )

    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()) {
                if (viewModel.isLoading) {
                    CircularProgressIndicator(
                        modifier = Modifier.align(Alignment.Center),
                        color = Color(0xFFFF1744)
                    )
                } else if (viewModel.error != null) {
                    Text(
                        text = "Erro: ${viewModel.error}",
                        color = Color.White,
                        modifier = Modifier.align(Alignment.Center).padding(16.dp),
                        textAlign = TextAlign.Center
                    )
                } else {
                    LazyVerticalGrid(
                        columns = GridCells.Fixed(2),
                        contentPadding = PaddingValues(8.dp)
                    ) {
                        items(viewModel.pokemonList) { pokemon ->
                            PokemonCard(pokemon)
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun PokemonCard(pokemon: PokemonBrief) {
    // Extrair ID da URL: "https://pokeapi.co/api/v2/pokemon/1/" -> "1"
    val id = pokemon.url.split("/").dropLast(1).lastOrNull() ?: "1"
    val imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$id.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(
                model = imageUrl,
                contentDescription = pokemon.name,
                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.padStart(3, '0')}",
                color = Color.Gray,
                fontSize = 12.sp
            )
        }
    }
}