Cubit en Práctica: Creando una Aplicación de Noticias Responsiva y con Soporte de Múltiples Idiomas. Parte 2
Esta es la segunda parte de esta serie de artículos, donde creamos una aplicación de noticias responsiva con soporte para tres idiomas: español, inglés y chino. Además, implementaremos una arquitectura escalable y añadiremos pruebas unitarias y de widgets.
Recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:
-
Parte 2: Inyección de dependencias, repositorios y la lógica de negocios.(este artículo)
-
Parte 3: Capa de presentación y soporte de múltiples idiomas.
Inyección de dependencias con GetIt
GetIt es un localizador de servicios que nos ayuda a cumplir con el Principio de Inversión de Dependencia, el cual tiene como objetivo reducir el acoplamiento entre los componentes de software y promover la flexibilidad y la facilidad de mantenimiento.
Vamos a crear un nuevo archivo llamado dependency_injection.dart
, donde inicializaremos GetIt y crearemos una función de ayuda para inyectar NewsDataSource
:
final getIt = GetIt.instance;
Future<void> injectDependencies() async {
getIt.registerLazySingleton(() => NewsDataSource());
}
Luego, en la función main()
, llamamos a injectDependencies()
para registrar las dependencias:
void main() async {
// Inyectamos las dependencias al iniciar la aplicación
injectDependencies();
runApp(const MyApp());
}
¿Por qué debemos usar inyección de dependencias en nuestra aplicación? Más adelante, cuando escribamos pruebas unitarias, podemos crear una clase "Mock", por ejemplo, MockNewsDataSource
, e inyectarla en las pruebas, lo que nos permitirá simular diferentes escenarios.
La capa de datos
Repositorio: NewsRepository
En esta pequeña aplicación, el repositorio solo es un intermediario entre las fuentes de datos y los cubits.
Nota
Recuerda que, en aplicaciones más grandes, el repositorio tiene varias funciones como gestionar los datos que provienen de la fuente de datos, transformarlos, guardarlos en la memoria caché o en la base de datos local, entre otras funciones
Creamos la clase NewsRepository
con las funciones fetchEverything
y fetchTopHeadlines
que llamaran a la fuente de datos para obtener los datos:
class NewsRepository {
// Obtiene la fuente de datos que inyectamos anteriormente
final NewsDataSource _dataSource = getIt();
// Llama a la fuente de datos para obtener todas las noticias
Future<List<Article>> fetchEverything({
required Locale locale,
String? search,
}) =>
_dataSource.fetchEverything(
language: locale.languageCode,
search: search,
);
// Llama a la fuente de datos para obtener los últimos encabezados
Future<List<Article>> fetchTopHeadlines({
required Locale locale,
}) =>
_dataSource.fetchTopHeadlines(
country: locale.countryCode!,
);
}
Por fin hemos terminado de codificar la capa de datos donde hemos creado la clase NewsDataSource, que actúa como la puerta de enlace a nuestras fuentes de datos externas, y hemos establecido un sólido conjunto de pruebas para garantizar su funcionamiento correcto en diversas situaciones. También a prendimos a utilizar la inyección de dependencias con GetIt, lo que nos brinda flexibilidad y facilidad de prueba.
La capa de la aplicación.
Esta capa es la que se encarga de manejar la lógica de negocio y la interacción directa con los usuarios. Esta capa interactúa directamente con la capa de datos para obtener y guardar información. Esta capa es una parte fundamental para escribir pruebas unitarias ya que la mayoría de la lógica de negocio se encuentra aquí.
Lógica de negocios: NewsCubit
La clase NewsCubit
se encargará de mantener el estado de la interfaz de usuario, así como llamar al repositorio para obtener las noticias que se mostraran al usuario. Pero antes de comenzar a codificar el cubit, veamos los posibles estados de la aplicación:
![Estados de la aplicación: de carga, exitoso y de error](/_next/image?url=https%3A%2F%2Ffirebasestorage.googleapis.com%2Fv0%2Fb%2Fyayo-code-blog-prod.appspot.com%2Fo%2Flocales%252Fes%252Fposts%252F6xxh4e60lq4x8fwwfkhu%252Fb765dbb0-9e83-47f8-8fd4-427e60c512d0.png%3Falt%3Dmedia%26token%3D851daff7-4dca-4ef8-9635-d9ce79c4f156&w=800&q=75)
Estados de la aplicación: de carga, exitoso y de error
-
Estado de carga: Durante este estado mostraremos una animación de carga en la interfaz de usuario. Durante este estado se está realizando una petición a la API. Lo representaremos con una clase llamada
NewsLoadingState
. -
Estado exitoso: Cuando la respuesta de la API es exitosa, entramos al estado exitoso y mostraremos una lista de noticias en la interfaz de usuario. Lo representaremos con una clase llamada
NewsSuccessState
. -
Estado error: Cuando la respuesta de la API es fallida, entramos al estado de error y mostramos el mensaje de error en la interfaz de usuario. Este estado lo representaremos con una clase llamada
NewsErrorState
.
Cuando creamos un cubit es necesario indicar el tipo del estado, por ejemplo, si el estado es un numero como en el ejemplo del contador el cubit se crea con el tipo int
:
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
}
Pero nosotros tenemos tres estados que son: NewsLoadingState
, NewsSuccessState
y NewsErrorState
¿cómo podemos asignarle al cubit los tres?. La respuesta es usando herencia:
// Todos los estados heredan de la clase NewsState
sealed class NewsState extends Equatable {
@override
List<Object> get props => [];
}
// Estado de carga
class NewsLoadingState extends NewsState {}
// Estado exitoso
class NewsSuccessState extends NewsState {
// Contiene la lista de noticias
final List<Article> news;
NewsSuccessState(this.news);
@override
List<Object> get props => [news];
}
// Estado de error
class NewsErrorState extends NewsState {
// Contiene el mensaje de error
final String message;
NewsErrorState(this.message);
@override
List<Object> get props => [message];
}
Ya hemos definidos los estados, así que ahora podemos crear la clase NewsCubit
de la siguiente manera:
class NewsCubit extends Cubit<NewsState> {
// El cubit tiene una dependencia en el repositorio
final NewsRepository _repository = getIt();
NewsCubit() : super(NewsLoadingState());
}
Para completar la implementación del cubit NewsCubit
debemos entender que tipo de interacciones puede tener el usuario con la aplicación. Veamos la siguiente imagen:
![Puntos donde el usuario puede interactuar con la aplicación](/_next/image?url=https%3A%2F%2Ffirebasestorage.googleapis.com%2Fv0%2Fb%2Fyayo-code-blog-prod.appspot.com%2Fo%2Flocales%252Fes%252Fposts%252F6xxh4e60lq4x8fwwfkhu%252Fa8d03799-fa2b-4844-bd95-c6ebc28f2358.png%3Falt%3Dmedia%26token%3Ddac86ea7-533d-4e1d-8e96-0d23c07eab2d&w=800&q=75)
Puntos donde el usuario puede interactuar con la aplicación
Al analizar la imagen anterior podemos ver que hay tres lugares donde el usuario puede interactuar con la aplicación:
-
Barra de búsqueda: El usuario puede introducir texto para buscar noticias de su interés.
-
Selección de idioma: Permite al usuario cambiar el idioma de la aplicación.
-
Botón borrar: Borra el contenido de la barra de búsqueda y muestra en la aplicación las ultimas noticias.
Ahora podemos crear tres funciones en nuestro cubit para manejar los eventos de búsqueda, cambio de idioma y presionar el botón borrar.
class NewsCubit extends Cubit<NewsState> {
final NewsRepository _repository = getIt();
// Variable de ayuda que contiene el texto que estamos buscando
String? _currentSearch;
// Variable de ayuda que contiene el idioma y país de las noticias que estamos buscando
Locale _currentLocale;
// Inicializamos "locale" en el constructor
NewsCubit(Locale locale)
: _currentLocale = locale,
super(NewsLoadingState());
// Cuando el usuario introduce un término de búsqueda llamamos esta función
Future<void> searchNews({
String? search,
}) async {
_currentSearch = search;
return _search();
}
// Cuando el usuario presiona el botón borrar llamamos esta función
Future<void> clearSearch() async {
_currentSearch = null;
return _search();
}
// Cuando el usuario cambia el idioma de la aplicación llamamos esta función
Future<void> setLocale(Locale newLocale) async {
if (_currentLocale == newLocale) return;
_currentLocale = newLocale;
return _search();
}
// Esta función abstrae la lógica de buscar para que el código no se repita en
// las funciones [searchNews], [clearSearch] y [setLocale]
Future<void> _search() async {
}
}
Ya hemos creado las tres funciones necesarias para que la capa de presentación pueda enviar los eventos generados por el usuario al cubit NewsCubit
. También podemos ver que las tres funciones tienen en común la lógica de buscar que vamos a crear en la función privada _search()
:
Future<void> _search() async {
// El código está dentro de un try-catch para capturar
// las excepciones lanzadas desde la capa de datos
try {
// Emitimos el estado de cargando para que la capa de presentación muestre la interfaz de carga
emit(NewsLoadingState());
// Usando un switch, si [_currentSearch] es nulo llamamos la API de las ultimas noticias
// pero si no es nulo llamamos la API de buscar todas las noticias
final news = await switch (_currentSearch) {
null => _repository.fetchTopHeadlines(
locale: _currentLocale,
),
_ => _repository.fetchEverything(
locale: _currentLocale,
search: _currentSearch,
)
};
// Emitimos el estado de éxito con la lista de noticias para que la capa de presentación muestre las noticias
emit(NewsSuccessState(news));
} on Exception catch (e) {
// En caso de cualquier excepción la capturamos y emitimos estado de error para que la capa
// de presentación muestre el error
if (e is MissingApiKeyException) {
emit(NewsErrorState('Please check the API key'));
} else if (e is ApiKeyInvalidException) {
emit(NewsErrorState('The api key is not valid'));
} else {
emit(NewsErrorState('Unknown error'));
}
}
}
Ya hemos completado todas las funciones, variables, etc. que conforman la clase NewsCubit
. Si ponemos todo junto, el código será:
class NewsCubit extends Cubit<NewsState> {
final NewsRepository _repository = getIt();
String? _currentSearch;
Locale _currentLocale;
NewsCubit(Locale locale)
: _currentLocale = locale,
super(NewsLoadingState());
Future<void> searchNews({
String? search,
}) async {
_currentSearch = search;
return _search();
}
Future<void> clearSearch() async {
_currentSearch = null;
return _search();
}
Future<void> setLocale(Locale newLocale) async {
if (_currentLocale == newLocale) return;
_currentLocale = newLocale;
return _search();
}
Future<void> _search() async {
try {
emit(NewsLoadingState());
final news = await switch (_currentSearch) {
null => _repository.fetchTopHeadlines(
locale: _currentLocale,
),
_ => _repository.fetchEverything(
locale: _currentLocale,
search: _currentSearch,
)
};
emit(NewsSuccessState(news));
} on Exception catch (e) {
if (e is MissingApiKeyException) {
emit(NewsErrorState('Please check the API key'));
} else if (e is ApiKeyInvalidException) {
emit(NewsErrorState('The api key is not valid'));
} else {
emit(NewsErrorState('Unknown error'));
}
}
}
}
Pruebas a la clase NewsCubit
Es hora de probar que la clase NewsCubit
funciona como esperamos. Para hacer pruebas a un cubit podemos usar el paquete de ayuda bloc_test que facilita hacer pruebas en cubits y blocs. Creamos un archivo llamado news_cubit_test.dart
dentro de la carpeta de test y establecemos la estructura básica:
// Mock nos permitirá regresar mock data al cubit desde el repositorio
class MockNewsRepository extends Mock implements NewsRepository {}
// Mock Locale que usaremos para pasar al cubit en el constructor y la función setLocale
const mockLocale = Locale('en', 'US');
// Llamar a la función fetchTopHeadlines regresara este articulo
const mockTopArticle = Article(title: "TopArticle", url: "someUrl");
// Llamar a la función fetchEverything regresara este articulo
const mockEverythingArticle = Article(title: "Everything", url: "someUrl");
void main() {
late MockNewsRepository mockRepo;
// setUp se llama antes de cada prueba.
setUp(() async {
mockRepo = MockNewsRepository();
// Inyectamos MockNewsRepository
getIt.registerSingleton<NewsRepository>(mockRepo);
});
// tearDown se llama después de cada prueba.
tearDown(() async {
// Reiniciamos getIt a su estado inicial
await getIt.reset();
});
}
Ahora tenemos que configurar MockNewsRepository
para que las funciones fetchTopHeadlines
y fetchEverything
regresen un artículo cuando sean llamadas:
setUp(() async {
mockRepo = MockNewsRepository();
getIt.registerSingleton<NewsRepository>(mockRepo);
// Cuando la función fetchEverything es llamada con mockLocale y cualquier
// termino de búsqueda regresa una lista con el articulo mockEverythingArticle
when(() => mockRepo.fetchEverything(
locale: mockLocale,
search: any(named: 'search'),
)).thenAnswer((_) async => [mockEverythingArticle]);
// Cuando la función fetchTopHeadlines es llamada con mockLocale
// regresa una lista con el articulo mockTopArticle
when(() => mockRepo.fetchTopHeadlines(locale: mockLocale))
.thenAnswer((_) async => [mockTopArticle]);
});
La primera prueba que vamos a hacer es verificar que llamar la función searchNews
emita los estados correctos y llame a la función del repositorio fetchTopHeadlines
correctamente:
blocTest<NewsCubit, NewsState>(
'When the search term is null '
'fetchTopHeadlines will be called '
'and the state will contain the mockTopArticle',
// Creamos el cubit con el mockLocale
build: () => NewsCubit(mockLocale),
// Llamamos la función searchNews
act: (cubit) async => cubit.searchNews(),
// Los estados deben ser emitidos en orden correcto
expect: () => [
NewsLoadingState(),
NewsSuccessState(const [mockTopArticle])
],
// Verificamos que la función fetchTopHeadlines fuera llamada 1 vez con el
// argumento mockLocale
verify: (cubit) {
verify(() => mockRepo.fetchTopHeadlines(locale: mockLocale)).called(1);
});
En la segunda prueba a la función searchNew
le vamos a pasar un texto de búsqueda por lo que la función del repositorio fetchEverything
debe ser llamada y los estados deben ser emitidos correctamente:
blocTest<NewsCubit, NewsState>(
'When the search term is not null '
'fetchEverything will be called '
'and the state will contain the mockEverythingArticle',
// Creamos el Cubit con el mockLocale
build: () => NewsCubit(mockLocale),
// Llamamos la función searchNews con un texto de búsqueda
act: (cubit) async => cubit.searchNews(search: 'Hello world'),
// Los estados deben ser emitidos en orden correcto
expect: () => [
NewsLoadingState(),
NewsSuccessState(const [mockEverythingArticle])
],
// Verificamos que la función fetchEverything fuera llamada 1 vez con el
// argumento mockLocale y 'Hello world'
verify: (cubit) {
verify(
() => mockRepo.fetchEverything(
locale: mockLocale,
search: 'Hello world',
),
).called(1);
});
La tercera prueba vamos a llamar a la función searchNews
con un texto de búsqueda y después llamaremos a las función clearSearch
, entonces debemos verificar que las funciones del repositorio fetchEverything
y fetchTopHeadlines
sean llamadas correctamente y además que los estados de NewsCubit
sean emitidos correctamente:
blocTest<NewsCubit, NewsState>(
'When the search term is not null '
'fetchEverything will be called '
'and then clearing the search will trigger a new search '
'then fetchTopHeadlines will be called '
'and states will be emitted correctly',
// Creamos el cubit con el mockLocale
build: () => NewsCubit(mockLocale),
// Llamamos la función searchNews con un texto de búsqueda
// y después llamamos la función clearSearch
act: (cubit) async {
await cubit.searchNews(search: 'Hello world');
await cubit.clearSearch();
},
// Los estados deben ser emitidos en orden correcto
expect: () => [
NewsLoadingState(),
NewsSuccessState(const [mockEverythingArticle]),
NewsLoadingState(),
NewsSuccessState(const [mockTopArticle])
],
// Verificamos que la función fetchEverything y fetchTopHeadlines
// fueran llamadas 1 vez con los argumentos correctos
verify: (cubit) {
verify(
() => mockRepo.fetchEverything(
locale: mockLocale,
search: 'Hello world',
),
).called(1);
verify(() => mockRepo.fetchTopHeadlines(locale: mockLocale)).called(1);
});
Vamos a agregar una última prueba, en esta prueba queremos verificar que el estado de error sea emitido correctamente si el repositorio lanza una excepción:
blocTest<NewsCubit, NewsState>(
'When the Api key is not valid exception is handled correctly',
build: () {
// Configuramos el mockRepo para lanzar una excepción ApiKeyInvalidException
// cuando la función fetchTopHeadlines sea llamada
when(() => mockRepo.fetchTopHeadlines(locale: mockLocale))
.thenAnswer((_) async => throw ApiKeyInvalidException());
// Creamos el cubit con el mockLocale
return NewsCubit(englishUs);
},
// Llamamos la función searchNews
act: (cubit) async => cubit.searchNews(),
// Los estados deben ser emitidos en orden correcto y
// el ultimo estado es el estado de error
expect: () => [
NewsLoadingState(),
NewsErrorState('The api key is not valid'),
],
);
Muy bien, ya hemos terminado la clase NewsCubit
que se encargará de mantener el estado de la aplicación y también hemos agregado pruebas para asegurarnos de que funcione como esperamos.
En el siguiente artículo trabajaremos con la capa de presentación donde aprenderemos cómo crear una aplicación responsiva y con soporte de múltiples idiomas.
Recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales: