Aprendiendo “BLoC” Fácil: Una Pokedex en tu app

Sebastián Salazar
15 min readDec 12, 2019

--

Hola chicos, este es mi primer artículo en Medium y trataré de explicar de la forma más clara posible cómo implementar el patrón y manejador de estado BLoC en una aplicación escrita en Flutter.

Para abordar el tema crearemos la típica Pokedex con scroll infinito.

Para esto usaremos la API rest open source: https://pokeapi.co

También ocuparemos los package: bloc, flutter_bloc, meta, http y equatable en las siguentes versiones respectivamente.

bloc: ^2.0.0

flutter_bloc: ^2.1.1

http: ^0.12.0+2

equatable: ^1.0.1

meta: ^1.1.7

Pero antes de empezar el desarrollo de la aplicación tenemos aprender bien qué es BLoC y el porqué de ocuparlo.

¿Que es BLoC?

BLoC (Business Logic Component) es un patrón que nos ayuda a manejar el estado de nuestra aplicación, con estado nos referimos a la parte que puede variar a través del tiempo y que con eso también se podría producir un eventual re-dibujado de la interface de nuestra aplicación. El SDK de flutter ya viene con una manera de representar los estados a través de los Widget con estado o StatefulWidgets.

Como su nombre lo indica los StatefulWidgets son Widgets que además de tener la capacidad de dibujar elementos en pantalla, estos pueden redibujarse de acuerdo del cambio de estado que este widget tenga. Pero la pregunta es: ¿A que me refiero con estado? Con estado nos referimos a toda la lógica de la aplicación que puede variar mediante una interacción con el usuario o bien una interacción con agentes externos, ya sea servicios web, uso de hardware del mismo dispositivo, etc.

Si bien ocupar StatefulWidgets en nuestra aplicación puede parecer fácil en una primera instancia, este se vuelve difícil de mantener por dos razones:

  1. Para poder obtener el estado de un widget desde otro widget se vuelve complejo, ya que para poder notificar de algún cambio a un widget que se encuentre en otra rama del árbol se tendría que recorrer todo el camino de padre e hijo.
  2. Mantener la lógica de la aplicación acoplada a los elementos de la interfaz se hace difícil de mantener y difícil de escalar, por lo tanto, el costo de cambiar algún elemento tanto UI como de la lógica es elevado y podemos romper la aplicación.

Comencemos entendiendo BLoC

Primero que todo para entender la manera en que funciona este patrón debemos entender algunos conceptos.

Bloc

Podríamos definirlo como el cerebro de nuestra aplicación, es el encargado de obtener eventos y de acuerdo de estos eventos generar estados para que la interfaz los muestre.

Eventos

Al momento de construir nuestra aplicación debemos pensar que acciones son las afectaran al estado, para eso definimos eventos, estos se encargan de notificar a nuestro bloc que cambia y como cambia el estado. Podríamos definir a los eventos como las acciones que entraran al bloc.

Estados

La salida de nuestro bloc sería los estados, estos contienen la información ya procesada para que la interfaz sea capaz de mostrarlas en pantalla.

Es bueno saber que este patrón de diseño se basa en el concepto de Stream el cual podríamos definir como un flujo de información constante.

Como vemos en la zona izquierda de la imagen, la capa de presentación (interfaz) le envía eventos al bloc y este le responde con un nuevo estado. Si miramos la parte derecha veremos el esquema común de cliente-servidor, en el cual el bloc le hace una petición al servidor(backend) y este le responde, todo esto de manera asíncrona.

Dicho esto, el flujo de nuestra aplicación sería:

La interfaz envía un evento al bloc, este recibe el evento y manda una petición al servidor, el servidor le responde al bloc y el bloc crea un nuevo estado con la respuesta y se la envía a la interfaz.

Una aplicación se puede componer de múltiples BLoC

Tal como sería una mala idea dejar todo la lógica de la aplicación en el mismo lugar que el código de la interfaz de usuario, también es una mala idea tener un solo bloc que contenga el estado global de toda la aplicación.

Se debería tener un bloc por cada funcionalidad o propósito que tenga nuestra aplicación, por ejemplo, si tuviéramos que desarrollar el login de una aplicación normal deberíamos tener dos bloc, uno que sea capaz de verificar el estado si un usuario esta autenticado o no (verificando si existe algún token en el dispositivo) y otro que sea capaz de manejar el estado del formulario y enviando los datos al backend y verificar si las credenciales son correctas o no para luego notificar el bloc de autenticación.

Comenzamos con el desarrollo!

Las librerías bloc y flutter_bloc contienen los recursos y widgets necesarios para implementar de forma satisfactoria este patrón.

Antiguamente cuando estas librerías aun no estaban disponibles, para la implementación de bloc se ocupaban Stream nativos de Dart o algunos de los métodos que provee la librería rxDart. Hoy por hoy no es necesario tener que utilizar estas herramientas ya que la librería oficial de bloc nos provee de todo lo necesario, reduciendo considerablemente el boilerplate que nos aportaban los otros métodos.

Esta será la estructura de carpetas que ocuparemos para nuestra aplicación, continuación detallare que propósito tiene cada una.

bloc: tendrá los eventos, estados y bloc respectivos de cada funcionalidad que implementemos (dentro de bloc estará la carpeta pokemons).

pages: tendrá el código de la interfaz (ahi estara el arbol de Widgets)

remote: Será el lugar donde tendremos la logica para recuperar la data desde el backend.

repository: Tendrá la lógica que conecta la data de internet con el bloc, por ejemplo si tuviéramos que guardar algún dato en la memoria interna del dispositivo, este seria un buen lugar para hacerlo.

Debo decir que esta es una estructura de carpetas bastante simple y quizás no es la mejor para crear aplicaciones mas grandes y que tengan un futuro crecimiento, pero a términos prácticos creo que es simple y cumple con lo necesario para este ejemplo.

Primero enfoquémonos en obtener los datos 🐶

Para esto dentro de la carpeta remote crearemos otra carpeta que se llamara model, esta tendrá la respuesta que nos dará la Api de Pokemon, de esta manera podremos ocupar la data como si fuera un objeto.

class PokemonResponse {
int count;
String next;
String previous;
List<Results> results;

PokemonResponse({this.count, this.next, this.previous, this.results});

PokemonResponse.fromJson(Map<String, dynamic> json) {
count = json['count'];
next = json['next'];
previous = json['previous'];
if (json['results'] != null) {
results = new List<Results>();
json['results'].forEach((v) {
results.add(new Results.fromJson(v));
});
}
}

Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['count'] = this.count;
data['next'] = this.next;
data['previous'] = this.previous;
if (this.results != null) {
data['results'] = this.results.map((v) => v.toJson()).toList();
}
return data;
}
}

class Results {
String name;
String url;

Results({this.name, this.url});

Results.fromJson(Map<String, dynamic> json) {
name = json['name'];
url = json['url'];
}

Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['name'] = this.name;
data['url'] = this.url;
return data;
}
}

Se que tomar un json y hacer todo el proceso de transformar a Dart Object es realmente aburrido, por suerte existe esta herramienta que es la que ocupe para obtener el código anterior: https://javiercbk.github.io/json_to_dart/

Ya que tenemos unas clases que nos servirán para tener una estructura definida de la respuesta de la Api tenemos que armar la petición.

Creamos la clase ApiBaseHelper con el propósito de tener un código re-utilizable que nos permita hacer una llamada GET a la Api, como ven este método recibe un string url que vendría el endpoint que utilizaremos

Este es el lugar donde podríamos escribir los métodos para los verbos POST, PUT, DELETE, si es que nuestro proyecto los requiere.

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'app_exception.dart';
class ApiBaseHelper {
final String _baseUrl = 'https://pokeapi.co/api/v2/';
Future<dynamic> get(String url) async {
var responseJson;
try {
final response = await http.get(_baseUrl + url);
responseJson = json.decode(response.body.toString());
} on SocketException {
print('Connection error');
}
return responseJson;
}
}

Luego de ya tener la clase para la respuesta de la Api y también tener el helper que nos ayudara a hacer el llamado debemos conectarlo todo. Para eso crearemos el archivo api_provider:

import 'package:poke_app/src/remote/api_base_helper.dart';
import 'package:poke_app/src/remote/models/pokemons_response.dart';
class ApiProvider {
ApiBaseHelper _helper = ApiBaseHelper();
Future<List<Results>> fetchPokemons({int offset = 0}) async {
final path = 'pokemon/?offset=$offset&limit=20';
final responseString = await _helper.get(path);
final response = PokemonResponse.fromJson(responseString).results;
return response;
}
}

En esta clase instanciamos el objeto _helper que es del tipo ApiBaseHelper, el cual nos proveerá del método get escrito anteriormente, a este método le pasamos el endpoint del listado de Pokemon.

Al método fetchPokemons le señalamos que este va a retornar un Future (Promesa en js) que tendrá un listado de Results (cada results sera un Pokemon), también señalamos que esta será una función asincrónica con la palabra reservada async y nos proporcionará la capacidad de esperar una respuesta de la Api con la palabra await. Como entrada de esta función tendrá una variable offset que será el ultimo pokemon que obtuvimos en la llamada anterior, por default será 0.

Por ultimo retornaremos response que es el resultado de ocupar el método .fromJson de nuestra clase de respuesta ya que es el encargado de parsear los datos de Map a nuestra clase PokemonResponse. (a este response ademas le haremos un .results para obtener la lista de resultados)

Definamos los Eventos

Para ya empezar a trabajar en nuestro bloc primero crearemos dentro de nuestra carpeta bloc una carpeta llamada pokemons(en plural por que manejara el listado de pokemon) y dentro de ella crearemos el archivo pokemons_event.dart.

import 'package:equatable/equatable.dart';abstract class PokemonsEvent extends Equatable {
const PokemonsEvent();
@override
List<Object> get props => [];
}
class AddMorePokemons extends PokemonsEvent {}

Primero creamos una clase abstracta (esto significa que no se podrá crear instancias de ella) que heredara de la clase Equatable, esta herencia nos permite comparar si un objeto pertenece a una clase o si dos objetos son iguales, para eso tenemos que declarar sus atributos dentro de la lista que retorna el método props.

Esta clase abstracta será padre de todos los eventos que tenga nuestro bloc, de esta manera que cada vez que creemos un nuevo evento para nuestro bloc, tendremos que heredar de PokemonsEvent, como es en el caso de AddMorePokemons.

Como pueden apreciar AddMorePokemons solo la dejamos declarada ya que no hace falta mas, pero si existiera el caso de que el evento tuviera que recibir un input de parte del usuario como en el caso de un formulario, ahí si seria necesario declarar atributos dentro del evento.

Ahora los Estados

Dentro de la carpeta pokemons que esta dentro de la carpeta bloc creamos el archivo pokemons_state.dart.

abstract class PokemonsState extends Equatable {
const PokemonsState();
}
class WithoutPokemonsState extends PokemonsState {
final List<Results> pokemons = [];
@override
List<Object> get props => [pokemons];
}
class WithPokemonsState extends PokemonsState {
final List<Results> pokemons;
final int amount;
WithPokemonsState({@required this.pokemons, @required this.amount});@override
List<Object> get props => [this.pokemons, this.amount];
}

Los estados tienen la misma estructura que los eventos, con la diferencia que estos representan la salida de nuestro bloc, estos son los encargados de decirle a nuestra interfaz que tiene que dibujar. En este caso tiene que dibujar la cantidad de Pokemon que el estado tenga.

Para eso creamos una clase abstracta que sera padre de las otras dos clases que estan en este archivo, esta clase se llamara PokemonsState y heredara de Equatable para poder ser comparable.

Las otras dos clases que heredan de PokemonsState representan dos estados, WithoutPokemonsState representa el estado que aun no a cargado los Pokemon desde la Api y WithPokemonState que representa el estado que si tiene cargada la data de los Pokemon.

WithoutPokemonState tiene solo un atributo que es un listado vacío de Results que representan los Pokemon (aun que también podríamos haber dejado este estado sin ningún atributo, ya que nunca consultaremos sus atributos).

WithPokemonState tiene dos atributos, el primero es un listado de Pokemon (representados por la clase Results) y el segundo es un Int amout que representa la cantidad de Pokemon que están en el estado, si bien este atributo no tiene un uso practico es necesario ya que si dejáramos solo el listado de Pokemon aunque la cantidad de Pokemon cambiara, el bloc creerá que el estado no ha cambiado ya que este solo lo ve como una lista y no se fija en su tamaño y con eso no se re-dibujaran los elementos en pantalla, para mostrarle a nuestro bloc que nuestro estado cambia es necesario hacer este truco.

Antes de armar nuestro Bloc, veamos el repository 🐕

Como hemos señalado anteriormente es mala idea acoplar lógica, cada parte de nuestra aplicación debería tener un solo propósito. Por eso el procesamiento de datos no debería en el bloc como tal, para eso existe el concepto de repository.

En la carpeta repository creamos un archivo llamado pokemon_repository.dart.

import 'package:poke_app/src/remote/api_provider.dart';
import 'package:poke_app/src/remote/models/pokemons_response.dart';
class PokemonRepository {
ApiProvider apiProvider = ApiProvider();
List<Results> pokemons = [];
int count = 0;
Future<List<Results>> fetchPokemons() async {if (this.pokemons.isEmpty) {
this.pokemons.addAll(await apiProvider.fetchPokemons());
this.count = this.pokemons.length;
return pokemons;
}
this.pokemons.addAll(await apiProvider.fetchPokemons(offset: this.count));
this.count = this.pokemons.length;
return pokemons;
}
}

Primero creamos una instancia de un objeto de ApiProvider que es el encargado de recuperar la data de la Api, también tenemos como atributo una lista de Results y un Int Count para saber cuántos pokemon tenemos en la lista.

Escribimos una función llamada fetchPokemon, esta función retornará un Future de una lista de results y por esto será una función async.

Esta función se encargará de obtener datos, si ya hay datos en la lista agrega los nuevos y se los pasa al bloc, como ven esta lógica perfectamente podría estar en el bloc, pero no conviene juntar propósitos y tener lógicas acopladas.

Al fin trabajaremos en el bloc

Adentro de la carpeta pokemons que esta dentro de la carpeta bloc creamos el siguiente archivo: pokemons_bloc.dart

import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import 'package:poke_app/src/bloc/pokemons/pokemons_event.dart';
import 'package:poke_app/src/bloc/pokemons/pokemons_state.dart';
import 'package:poke_app/src/repository/pokemon_repository.dart';
class PokemonsBloc extends Bloc<PokemonsEvent, PokemonsState> {
PokemonRepository pokemonRepository;
PokemonsBloc({@required this.pokemonRepository})
: assert(pokemonRepository != null);
@override
PokemonsState get initialState => WithoutPokemonsState();
@override
Stream<PokemonsState> mapEventToState(
PokemonsEvent event,
) async* {
if (event is AddMorePokemons) {
try {
final pokemons = await this.pokemonRepository.fetchPokemons();
yield (WithPokemonsState(pokemons: pokemons, amount: pokemons.length));
} catch (error) {
print(error);
}
}
}
}

En la lógica del bloc seremos mas específicos y detallados en la explicación de cada parte del código.

class PokemonsBloc extends Bloc<PokemonsEvent, PokemonsState> {

La clase PokemonsBloc hereda de la clase Bloc que es de tipo <PokemonState, PokemonEvent>.

PokemonRepository pokemonRepository;PokemonsBloc({@required this.pokemonRepository})
: assert(pokemonRepository != null);

Tenemos como atributo un objeto pokemonRepository que es del tipo PokemonRepository, este atributo se lo pasamos por el constructor al bloc, de esta manera nos aseguramos de que se cree una instancia fuera de el.

: assert(pokemonRepository != null);

Con esta sentencia nos aseguramos de que el objeto pokemonRespository nunca sea nulo.

@override
PokemonsState get initialState => WithoutPokemonsState();

El método initialState asigna al bloc un estado inicial, en el caso del listado de Pokemon asignamos el estado WithoutPokemonsState como primer estado.

@override
Stream<PokemonsState> mapEventToState(
PokemonsEvent event,
) async* {
if (event is AddMorePokemons) {
try {
final pokemons = await this.pokemonRepository.fetchPokemons();
yield (WithPokemonsState(pokemons: pokemons, amount: pokemons.length));
} catch (error) {
print(error);
}
}
}

El método mapEventToState es el corazón del bloc, este método es una función generadora (estas se denotan con un * después de la palabra reservada async) y permiten ocupar la sentencia yield, se podría decir que es el homologo de return solo que esta no corta el hilo de ejecución.

Si nos fijamos en la declaracion de esta funcion:

Stream<PokemonsState> mapEventToState(PokemonsEvent event) async* {

Esta recibe como argumento un evento y retorna un Stream de estados, siguiendo las características de los Stream y de las funciones generadoras, este método siempre que llega un nuevo evento al bloc envía un estado.

Ya tenemos lista la lógica de nuestro bloc, Veamos la UI

Dentro de la carpeta pages creamos el archivo home_page.dart.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:poke_app/src/bloc/pokemons/pokemons_bloc.dart';
import 'package:poke_app/src/bloc/pokemons/pokemons_event.dart';
import 'package:poke_app/src/bloc/pokemons/pokemons_state.dart';
import 'package:poke_app/src/remote/models/pokemons_response.dart';
class HomePage extends StatelessWidget {
PokemonsBloc pokeBloc;
bool scrollSwitch = true;
@override
Widget build(BuildContext context) {
pokeBloc = BlocProvider.of<PokemonsBloc>(context);
return Scaffold(
appBar: AppBar(
title: Text('Poke App'),
),
body: _body(context),
);
}
Widget _body(BuildContext context) {
return BlocBuilder<PokemonsBloc, PokemonsState>(
bloc: pokeBloc,
builder: (context, state) {
if (state is WithoutPokemonsState) {
pokeBloc.add(AddMorePokemons());
return Center(child: CircularProgressIndicator());
}
if (state is WithPokemonsState) {
scrollSwitch = true;
return _list(state);
}
return Container();
},
);
}
Widget _list(WithPokemonsState state) {
final scrollController = ScrollController();
scrollController.addListener(() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200 &&
scrollSwitch == true) {
scrollSwitch = false;
pokeBloc.add(AddMorePokemons());
}
});
return ListView.builder(
controller: scrollController,
itemCount: state.pokemons.length,
itemBuilder: (BuildContext context, int index) {
return _listTile(state.pokemons, index);
},
);
}
Widget _listTile(List<Results> pokemons, int index) {
final chunks = pokemons[index].url.split('/');
var id = chunks[6];
return ListTile(
leading: Icon(Icons.arrow_right),
title: Text(pokemons[index].name),
trailing: Image(
image: NetworkImage(
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png'),
),
);
}
}

Primero vemos la que la clase HomePage(la interfaz o UI) heredara sus métodos de un StatelessWidget, es decir aparentemente no tendrá estado, pero gracias con el bloc podremos hacer que este lo tenga y se vaya re-dibujando de acuerdo a esta. Para que el código sea mas legible es mejor ir separando la interfaz en pequeños componentes.

pokeBloc = BlocProvider.of<PokemonsBloc>(context);

Ocupamos la sentencia BlocProvider.of<PokemonsBloc>(context) para “instanciar” el bloc por que de esa forma hacemos una inyección de dependencias ya que el bloc ya fue instanciado en una zona que veremos mas adelante.

Widget _body(BuildContext context) {
return BlocBuilder<PokemonsBloc, PokemonsState>(
bloc: pokeBloc,
builder: (context, state) {
if (state is WithoutPokemonsState) {
pokeBloc.add(AddMorePokemons());
return Center(child: CircularProgressIndicator());
}
if (state is WithPokemonsState) {
scrollSwitch = true;
return _lista(state);
}
return Container();
},
);
}

En el metodo _body sera el lugar donde ocuparemos el Widget BlocBuilder, este builder nos ayudara a re-dibujar los Widgets que esten por debajo de este en su rama del arbol de Widgets. Este Builder es el homologo y abstracción de StreamBuilder, ambos se pueden ocupar para este patrón pero BlocBuilder reduce mucho el boilerplate, ademas que proviene de la librería “flutter_bloc” que es la que estamos ocupando en esta implementacion.

Como se puede ver dentro del callback que le asignamos al parámetro builder, se puede preguntar sobre el estado que tiene el bloc en este momento y de esta manera poder realizar una acción dependiendo de la lógica que deseamos implementar.

Cuando el estado es WithoutPokemonsState dibujamos un CircularProgressIndicator y añadimos al bloc el evento AddMorePokemons con la siguente sentencia:

pokeBloc.add(AddMorePokemons());

Con esto enviaremos un nuevo evento AddMorePokemon al bloc provocando que este obtenga nueva data desde su repository para mandar el estado WithPokemonsState con el listado de pokemon.

Cuando el estado es WithPokemonsState el BlocBuilder simplemente dibuja el listado de Pokemon y ponemos la variable booleana scrollSwitch en true.

Widget _list(WithPokemonsState state) {
final scrollController = ScrollController();
scrollController.addListener(() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200 &&
scrollSwitch == true) {
scrollSwitch = false;
pokeBloc.add(AddMorePokemons());
}
});
return ListView.builder(
controller: scrollController,
itemCount: state.pokemons.length,
itemBuilder: (BuildContext context, int index) {
return _listTile(state.pokemons, index);
},
);
}

En el método _list dibujamos la lista lo cual es algo súper sencillo, solo que en este caso queremos implementar un infinty scroll para eso instanciamos una variable tipo ScrollController a la que le añadiremos un listener para escuchar el momento en el que la posición en que nos encontramos del scroll sea mayor a la poscion maxima del scroll — 200 pixeles y que la variable scrollSwitch sea true, cuando eso suceda agregaremos el evento addMorePokemons al bloc y haremos que la variable scrollSwitch sea falsa (esta variable se hace true cuando el bloc tiene un estado WithPokemonsState).

El scrollController se lo damos como parámetro controller al ListView.Builder.

Widget _listTile(List<Results> pokemons, int index) {
//corta la url de la imagen en pedazos
final chunks = pokemons[index].url.split('/');
//en la parte 6 se encuentra el id
var id = chunks[6];
return ListTile(
leading: Text(id),
title: Text(pokemons[index].name),
trailing: Image(
image: NetworkImage(
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png'),
),
);
}

El método _listTile solo dibuja el ítem de la lista, lo único señalable es que obtiene la id haciendo un .Split a la url que esta en el atributo .url de la clase Results, con esto obtiene la imagen del pokemon respectivo.

Los ultimos detalles

Ya tenemos nuestra aplicacion casi lista, solo nos hace falta ver los siguentes archivos.

app.dart

import 'package:flutter/material.dart';
import 'package:poke_app/src/pages/home_page.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primaryColor: Colors.red),
debugShowCheckedModeBanner: false,
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
},
);
}
}

Este es el archivo común donde se encuentra el MaterialApp, no hay mucho mas que decir al respecto.

main.dart

import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:poke_app/src/app.dart';
import 'package:poke_app/src/bloc/pokemons/pokemons_bloc.dart';
import 'package:poke_app/src/repository/pokemon_repository.dart';
void main() {
PokemonRepository pokemonRepository = PokemonRepository();
runApp(MultiBlocProvider(
providers: [
BlocProvider<PokemonsBloc>(
create: (BuildContext context) => PokemonsBloc(pokemonRepository: pokemonRepository),
)
],
child: MyApp(),
));
}

Aquí estará el Widget MultiBlocProvider, este es el lugar donde instanciaremos los blocs que estarán presentes durante toda la aplicación. Como ven aquí instanciamos nuestro PokemonsBloc y gracias a esto pudimos hacer la pequeña inyección de dependencia en el HomePage.

Congratulation

Eso es todo por hoy, sugiero que bajen el proyecto completo desde el siguente repositorio en github:

https://github.com/Walaleitor/pokeApp_infintyScroll

Espero que les haya ayudado!

Documentación oficial de BLoC:

https://bloclibrary.dev/

Me puedes encontrar en GitHub, Instagram y Twitter.

--

--