3 - Consumiendo APIs
Objetivo
En este laboratorio aprenderemos sobre conexión a sistemas externos.
Para este laboratorio vamos a desarrollar un Pokedex, que es una unidad de información para el mundo Pokemon. Esta aplicación nos mostrara los datos de cada criatura y desde ahí podremos generar un detalle.
Esta aplicación seguirá evolucionando conforme avancemos.
**Nota: Al final de este laboratorio te recomiendo que generes una copia para que en los próximos tengas un punto de comparación sobre lo que se va avanzando.
Instrucciones
Sigue los pasos descritos en la siguiente práctica, si tienes algún problema no olvides que tus profesores están para apoyarte.
API
Para este laboratorio estaremos utilizando el API de PokeAPI los endpoints con los que vamos a comenzar son los siguientes:
GET https://pokeapi.co/api/v2/pokemon/?limit=1279
GET https://pokeapi.co/api/v2/pokemon/:number_pokemon/
Laboratorio
Paso 1: Configuración de HTTP para Conexiones API
En el laboratorio anterior creamos nuestra interfaz de lista con algunos datos de prueba. Ahora vamos a conectarla con una API real para mostrar información actualizada y las imágenes de los Pokémon.
Cuando configuramos nuestro proyecto en el primer laboratorio, añadimos las dependencias http y dio. Para este tutorial usaremos principalmente el paquete http, que es la solución más común para realizar peticiones HTTP en Flutter.
Es importante recordar que conectarse directamente a bases de datos desde el cliente móvil no es una buena práctica, ya que genera riesgos importantes de seguridad. Por eso utilizamos APIs como intermediarios. Servicios como Firebase ofrecen soluciones de tipo BaaS (Backend as a Service) que facilitan este proceso.
Arquitectura para Conexiones API
Para implementar nuestras conexiones API de manera estructurada, seguiremos los siguientes pasos:
- Crear una capa de servicio
- Implementar un repositorio para manejo de datos
- Crear servicios HTTP para realizar las llamadas
- Implementar las conexiones en nuestra UI
Creando la Capa de Servicio
Primero, vamos a crear una carpeta services dentro de nuestra carpeta lib. En ella, crearemos un archivo llamado pokemon_api_service.dart:
// lib/services/pokemon_api_service.dart
abstract class PokemonApiService {
// https://pokeapi.co/api/v2/pokemon/?limit=1279
Future<Map<String, dynamic>?> getPokemonList(int limit);
// https://pokeapi.co/api/v2/pokemon/{number_pokemon}/
Future<Map<String, dynamic>?> getPokemonInfo(int numberPokemon);
}
Aquí definimos una clase abstracta con los métodos que necesitaremos para conectar con nuestra API. Usamos Future para manejar operaciones asíncronas
Los parámetros van a variar según lo que se pida en el API, no vamos a ahondar en como se detalla API REST en este curso, pero retomaremos las bases de la siguiente forma.
GET Utiliza el query como parámetros, esto es todo lo que va adelante de la url después de ? y va en la forma param=valor separados cada uno por un &. Un ejemplo sería como tenemos el API.
?limit=1279
Si tuviéramos más parámetros sería por ejemplo
?limit=1279&offset=0
En el ejemplo de la segunda API tenemos el caso donde
/pokemon/:number_pokemon/
:number_pokemon es el parámetro que estamos pasando, en este caso un número Entero.
POST Para el POST lo más común es utilizar el BODY de la petición HTTP, en este caso no es posible verlo desde la URL ya que como el nombre lo indica va decodificado dentro del paquete. Casi siempre en estos casos y para el Retrofit existe una forma de codificar el BODY y pasarlo a la URL.
Como resumen tenemos, una llamada función que recibe un query param de la variable limit de tipo Int y como resultado nos da un objeto Map. También, tenemos una llamada a /pokemon que recibe un param de tipo Int que en este caso es el número de pokemon y como resultado nos regresa un objeto Map.
Si aún te cuesta trabajo entenderlo, no te preocupes en la práctica debería quedarte más claro.
Y listo, con esto ya tenemos configurado el primer paso.
Crear una capa de servicio- Implementar un repositorio para manejo de datos
- Crear servicios HTTP para realizar las llamadas
- Implementar las conexiones en nuestra UI
Implementando el Repositorio
Para este paso ha llegado el momento de incorporar uno de los elementos primordiales que hacen que un proyecto móvil este bien estructurado hoy en día.
Esto es un Patrón de Diseño, estos los verás en mayor detalle en tu clase de Ingeniería de Software. Para entrar de lleno cubriremos el patrón Repository, este patrón nos ayuda a abstraer la capa de datos para hacer más fácil el manejo de la información y no hacerlo todo por ejemplo dentro de los View
Vamos a crear una carpeta repositories y dentro un archivo pokemon_repository.dart.
Esta clase es la que se conectará a nuestro servicio y a partir de ella se podrán cargar los datos hacia el content_view
- Agregamos las librerías de http y dart:convert e importamos el modelo y el servicio previamente creado
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/pokedex_model.dart';
import '../services/pokemon_api_service.dart';
- Inicializamos la url base y los endpoint a utilizar:
class PokemonRepository implements PokemonApiService {
// API URLs
static const String baseUrl = "https://pokeapi.co/api/v2";
static const String pokemonEndpoint = "/pokemon";
}
- Creamos el método para obtener la lista de todos los pokemon
@override
Future<Map<String, dynamic>?> getPokemonList(int limit) async {
// Construcción del endpoint y sus paramétros
final url = Uri.parse('$baseUrl$pokemonEndpoint?limit=$limit');
try {
// Llamada del endpoint
final response = await http.get(url);
// Verificar el código de respuesta
if (response.statusCode == 200) {
// Decodificar la respuesta a json
return json.decode(response.body);
} else {
print('Error fetching pokemon list: ${response.statusCode}');
return null;
}
} catch (e) {
print('Exception occurred: $e');
return null;
}
}
- Como parte de nuestro servicio nos falta implementar el método getPokemonInfo(int numberPokemon)
@override
Future<Map<String, dynamic>?> getPokemonInfo(int numberPokemon) async {
// Construcción de la url
final url = Uri.parse('$baseUrl$pokemonEndpoint/$numberPokemon');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
print('Error fetching pokemon info: ${response.statusCode}');
return null;
}
} catch (e) {
print('Exception occurred: $e');
return null;
}
}
Con esto debe quedar claro que cada método que definimos en nuestro servicio deberá tener su correspondiente en el repositorio para introducir la lógica de llamar el API y manejar su resultado.
En Flutter es muy común generar métodos de conveniencia que facilitan las llamadas y conversiones de los datos con nuestros modelos y también para separar las responsabilidades, implementemos los siguientes métodos:
Future<Pokedex?> getPokedexData(int limit) async {
final responseData = await getPokemonList(limit);
if (responseData != null) {
return Pokedex.fromJson(responseData);
}
return null;
}
Future<PokemonProfile?> getPokemonProfileData(int numberPokemon) async {
final responseData = await getPokemonInfo(numberPokemon);
if (responseData != null) {
return PokemonProfile.fromJson(responseData);
}
return null;
}
Y así tenemos configurado el siguiente paso:
Crear una capa de servicioImplementar un repositorio para manejo de datos- Actualizando los Modelos para JSON
- Implementar las conexiones en nuestra UI
Actualizando los Modelos para JSON
Para que nuestros modelos funcionen con JSON, necesitamos actualizar nuestros modelos para hacerlos compatibles.
Modifica PokemonBase la propiedad de perfil para hacerla opcional quedando de la siguiente manera:
class PokemonBase {
final int id;
final Pokemon pokemon;
final PokemonProfile? profile; // Hacemos el perfil opcional para carga progresiva
PokemonBase({
required this.id,
required this.pokemon,
this.profile,
});
}
Esto mejora la carga porque permite una carga progresiva (lazy loading) en lugar de requerir que toda la información esté disponible de inmediato.
Si profile fuera obligatorio (required this.profile), cada vez que instanciemos un PokemonBase, tendríamos que cargar toda la información del Pokémon, incluso si solo necesitamos datos básicos como el nombre e ID.
Y así tenemos configurado el siguiente paso:
Crear una capa de servicioImplementar un repositorio para manejo de datosActualizando los Modelos para JSON- Implementar las conexiones en nuestra UI
Nota: si tus modelos no cuentan con factory json deberás implementarlos
Paso 2: Implementando las Conexiones en nuestra UI
Ahora vamos a actualizar nuestra pantalla principal para usar nuestro repositorio.
En nuestro archivo content_view, ejecuta los siguientes cambios:
- Elimina la variable pokemonList del stateful widget
- Modifica la variable _pokemonList del state para que sea final
- Crea un instancia del repositorio en el state
¿Si pudiste?
Estos cambios deben quedar de la siguiente manera:
class PokemonListView extends StatefulWidget {
const PokemonListView({Key? key}) : super(key: key);
@override
_PokemonListViewState createState() => _PokemonListViewState();
}
class _PokemonListViewState extends State<PokemonListView> {
final List<PokemonBase> _pokemonList = [];
final PokemonRepository _repository = PokemonRepository();
Continuemos vamos a crear el método _loadPokemonList()
para llamarlo desde la función initState() para que este se ejecute cuando la viste se muestre en la app. No te precipites en copiar y pegar para que entiendas el código
En este código:
- Creamos un widget StatefulWidget para manejar el estado
- Usamos initState() para cargar los datos cuando el widget se crea
- Implementamos _loadPokemonList() para obtener los datos de la API
- Usamos setState() para actualizar la UI cuando los datos están disponibles
Future<void> _loadPokemonList() async {
try {
final pokedex = await _repository.getPokedexData(20); // Limitamos a 20 para eficiencia
if (pokedex != null) {
final List<PokemonBase> tempList = [];
for (var pokemon in pokedex.results) {
// Extraer el número del Pokémon de la URL
final urlParts = pokemon.url.split('/');
final pokemonNumber = int.parse(urlParts[urlParts.length - 2]);
// Obtener información detallada (incluidas imágenes)
final profile = await _repository.getPokemonProfileData(pokemonNumber);
// Crear objeto PokemonBase con todos los datos
final pokemonBase = PokemonBase(
id: pokemonNumber,
pokemon: pokemon,
profile: profile,
);
// Añadir a la lista temporal
tempList.add(pokemonBase);
// Actualizar el estado para mostrar progresivamente los Pokémon cargados
setState(() {
_pokemonList.add(pokemonBase);
});
}
setState(() {
_isLoading = false;
});
}
} catch (e) {
print('Error al cargar la lista de Pokémon: $e');
setState(() {
_isLoading = false;
});
}
}
Por lo tanto initState queda de la siguiente manera:
@override
void initState() {
super.initState();
_loadPokemonList();
}
Nota que en la función de _loadPokemonList modificamos el state de una variable isLoading agregala a la definición de variables del state para que puedas mostrar un loader mientras se muestran los datos de nuestro pokedex.
¿Ya pudiste?
Queda así:
class _PokemonListViewState extends State<PokemonListView> {
final List<PokemonBase> _pokemonList = [];
final PokemonRepository _repository = PokemonRepository();
bool _isLoading = true;
¿Ahora como lo usamos en la interfaz si solamente se puede tener un hijo en el Scaffold?
Basta con utilizar un if de la siguiente manera en el body
body: _isLoading && _pokemonList.isEmpty
? const Center(child: CircularProgressIndicator())
: ListView.builder(....
Ahora tenemos un error imageUrl: pokemonBase.profile.sprites.frontDefault
¿por qué crees que suceda?
Te recuerdo que modificamos el perfil que puede ser opcional para mejorar la carga para arreglar el error dejalo así:
return ListTile(
leading: pokemonBase.profile?.sprites.frontDefault != null
? CachedNetworkImage(
imageUrl: pokemonBase.profile!.sprites.frontDefault,
placeholder: (context, url) => const SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
width: 48,
height: 48,
): Container(
width: 48,
height: 48,
color: Colors.grey[300],
),
Se valida que el perfil exista y si no muestra un un contenedor color gris
Ahora ejecutamos la aplicación y el resultado final debera mostrarse en tu dispositivo, si se tarda un ratito ten paciencia está cargando :)
Y así hemos cumplido todos los pasos:
Crear una capa de servicioImplementar un repositorio para manejo de datosActualizando los Modelos para JSONImplementar las conexiones en nuestra UI
Con esto ya tenemos una lista completa con datos desde nuestra API, el proyecto ya cuenta con muchos archivos, es momento de empezar a aplicar Arquitectura.
Esto lo haremos en el próximo laboratorio por lo que trata que el resultado final sea hasta donde vamos, si tienes dudas pide una asesoría.
Nota: Al final de este laboratorio te recomiendo que generes una copia para que en los próximos tengas un punto de comparación sobre lo que se va avanzando.