Saltar al contenido principal

Cubit: App de Noticias Responsiva con Soporte de Múltiples Idiomas

Introducción

En este artículo crearemos 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. En la aplicación, mostraremos los últimos titulares y ofreceremos la opción de buscar noticias de tu interés, así como de seleccionar tu idioma preferido.

App de noticias responsiva con soporte para tres diferentes tamaños.

App de noticias responsiva con soporte para tres diferentes tamaños.

info

Puedes encontrar el código fuente en GitHub.

Propósito de este tutorial

  • Crear una app responsiva con soporte para dispositivos pequeños, medianos y grandes.
  • Aprender a usar cubit como gestor de estados.
  • Crear una arquitectura sencilla que sea escalable y nos permita hacer pruebas.
  • Aprender cómo agregar pruebas unitarias y de widgets a nuestro proyecto.
  • Realizar peticiones a una API REST usando http.
  • Aplicar inyección de dependencias con GetIt (no confundir con GetX).

Prerrequisitos (Opcional)

Arquitectura de la aplicación

Cuando creamos una aplicación, definir una arquitectura es muy importante porque tiene muchos beneficios, como:

  • Mantenibilidad: Una arquitectura sólida facilita la detección y corrección de errores, así como la incorporación de nuevas actualizaciones y características.

  • Facilita el trabajo en equipo: Una arquitectura bien definida es como una guía para los desarrolladores, porque todos saben cómo es la estructura del proyecto, lo que reduce los conflictos.

  • Calidad del código: Con una arquitectura, podemos definir estándares y mejores prácticas que todo el equipo debe seguir, lo que crea consistencia y calidad en el proyecto.

  • Reusabilidad del código: Una buena arquitectura nos ayuda a abstraer secciones de código que podemos reutilizar en diferentes partes del proyecto.

  • Código más testeable: Una buena arquitectura permite separar las diferentes partes de la aplicación en secciones con responsabilidades específicas, lo que facilita probar cada sección de forma aislada sin depender de otras secciones.

Hay muchas otras razones para tener una arquitectura que también son muy importantes, pero en resumen, definir una arquitectura nos ayuda a crear código mantenible, escalable, testeable y que promueve la colaboración del equipo.

La arquitectura de este proyecto es muy similar a la que se puede encontrar en la documentación de Flutter Bloc.

Arquitectura: fuentes de datos, repositorios, lógica de negocios y capa de presentación

Arquitectura: fuentes de datos, repositorios, lógica de negocios y capa de presentación

Como podemos ver en la imagen anterior, la arquitectura está dividida en tres partes: las fuentes de datos externas, la capa de datos y la capa de la aplicación. ¿Cuál es la responsabilidad de cada una?

  • Las fuentes externas de datos: Se refieren a las diferentes fuentes de donde una aplicación puede obtener información, por ejemplo, una base de datos, API web, archivos locales, sensores del dispositivo, etc.

  • La capa de datos: Se encarga de interactuar con las fuentes de datos y permite que otras partes de la aplicación accedan a los datos de manera organizada y segura. Esta capa se divide a su vez en dos:

    • Fuentes de datos: Son las encargadas de conectarse directamente con las fuentes de datos externas.

    • Repositorios: Son intermediarios entre la capa de datos y la capa de la aplicación. Se encargan de 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.

  • La capa de la aplicación: Se encarga de manejar la lógica de negocio y la interacción directa con los usuarios. Se divide en dos partes:

    • Lógica de negocios: Aquí es donde se encuentran nuestros blocs y cubits. Es donde vamos a escribir acerca de la lógica de negocios y también manejar el estado de nuestros widgets.

    • Capa de presentación: Aquí es donde están todos nuestros widgets y se encarga de las interacciones y de lo que el usuario puede ver: botones, imágenes, texto, etc.

note

En este artículo, no abordaremos la parte punteada, que implica conectarnos a una base de datos local para almacenar noticias y poder acceder a ellas cuando no tengamos conexión.

Fuente de datos externa

News API

En nuestra aplicación, la fuente de datos externa es News API, y hay dos API que nos interesan:

  1. /v2/everything: Esta API busca a través de millones de artículos y la utilizaremos cuando el usuario introduzca un texto en la barra de búsqueda.

  2. /v2/top-headlines: Esta API proporciona las noticias principales y titulares de última hora para un país, y la usaremos en la página de inicio.

En la documentación, podemos ver que al llamar cualquiera de estas dos API, cuando la respuesta es exitosa, retornan un JSON como este:

{
"status": "ok",
"totalResults": 1,
"articles": [
{
"source": {
"id": "cbs-news",
"name": "CBS News"
},
"author": "Sophie Lewis",
"title": "\"Bachelor\" host Chris Harrison is temporarily \"stepping aside\" after \"excusing historical racism\" - CBS News",
"description": "\"By excusing historical racism, I defended it,\" Harrison wrote, announcing he will not host the \"After the Final Rose\" live special.",
"url": "https://www.cbsnews.com/news/chris-harrison-the-bachelor-stepping-aside-comments-rachel-lindsay-backlash/",
"urlToImage": "https://cbsnews1.cbsistatic.com/hub/i/r/2021/02/12/0cf2794b-2b04-4ced-84ce-1d72e26ed2cb/thumbnail/1200x630/8d464cc0fdf23566bbea2e01c41401ab/gettyimages-1204035255.jpg",
"publishedAt": "2021-02-14T12:18:00Z",
"content": "Chris Harrison, the longtime host of \"The Bachelor\" and \"The Bachelorette,\" announced Saturday that he is \"stepping aside\" from the franchise for an undisclosed \"period of time.\" The bombshell announ… [+7754 chars]"
}
]
}

Pero cuando la petición no es exitosa, la respuesta contiene un código de error (ver aquí), y el JSON es como este:

{
"status": "error",
"code": "apiKeyMissing",
"message": "Your API key is missing. Append this to the URL with the apiKey param, or use the x-api-key HTTP header."
}

Si analizamos ambas respuestas, podemos notar que necesitamos dos clases para modelar la respuesta. La primera clase la vamos a llamar Article y representará todo lo que está dentro de la lista de articles:

lib/src/model/article.dart
()
class Article extends Equatable {
final String title;
final String? author;
final String? description;
final String? urlToImage;
final String? content;
final String url;

const Article({
required this.title,
required this.url,
this.author,
this.content,
this.urlToImage,
this.description,
});

factory Article.fromJson(Map<String, dynamic> json) =>
_$ArticleFromJson(json);

Map<String, dynamic> toJson() => _$ArticleToJson(this);


List<Object?> get props =>
[title, author, description, urlToImage, content, url];
}
note

Podemos ver que en la clase Article estamos usando los siguientes paquetes:

  • Equatable: Es un paquete que simplifica el proceso de comparar dos objetos de la misma clase sin sobreescribir == y hashCode. Puedes visitar este artículo para aprender más.
  • JsonSerializable: Es un paquete para generar código que nos ayudará a convertir el JSON a objetos de la clase Article.

La segunda clase se encarga de representar los valores de la respuesta, ya sea exitosa o fallida. Como puedes ver, también estamos utilizando los paquetes Equatable y JsonSerializable:

lib/src/data_source/response/api_response.dart
()
class ApiResponse extends Equatable {
final String status;
final String? code;
final List<Article>? articles;

const ApiResponse(
this.status,
this.code,
this.articles,
);

factory ApiResponse.fromJson(Map<String, dynamic> json) =>
_$ApiResponseFromJson(json);

Map<String, dynamic> toJson() => _$ApiResponseToJson(this);


List<Object?> get props => [status, code, articles];
}

Ya hemos definido las clases que nos ayudarán a representar el JSON en objetos de Dart. Ahora podemos continuar con la clase que se encargará de hacer las peticiones a la API.

tip

Recuerda ejecutar el comando para que el paquete JsonSerializable genere el código necesario: flutter pub run build_runner watch --delete-conflicting-outputs.

La capa de datos: fuente de datos

Como mencionamos anteriormente, esta capa es responsable de interactuar con las fuentes de datos, ya sea una API externa o una base de datos local. También permite que otras partes de la aplicación accedan a los datos de manera organizada y segura. Se divide en fuentes de datos y repositorios.

NewsDataSource

La clase NewsDataSource se encargará de hacer peticiones a las API de News API. Para esto vamos a usar el paquete http que nos permite hacer peticiones HTTP. Vamos a crear la clase NewsDataSource e inicializar algunas variables:

lib/src/data_source/news_data_source.dart
class NewsDataSource {

// Visita la página de https://newsapi.org/ para obtener tu API key
static const String apiKey = 'xxxxxxxxxxxxxxxxxxxxxxxxx';

static const String baseUrl = 'newsapi.org';
static const String everything = '/v2/everything';
static const String topHeadlines = '/v2/top-headlines';

final Client _httpClient;

NewsDataSource({Client? httpClient}) : _httpClient = httpClient ?? Client();
}

Hemos agregado la URL base de News API, así como la ruta de las dos API que vamos a utilizar. También podemos ver que tenemos la API KEY, la cual podemos obtener en el sitio web de News API. Por último, tenemos un objeto Client que se encargará de hacer peticiones a las API.

Si eres observador, te preguntarás por qué estamos inicializando el cliente de la siguiente forma:

  final Client _httpClient;

NewsDataSource({Client? httpClient}) : _httpClient = httpClient ?? Client();

En vez de hacerlo así:

  final Client _httpClient = Client();

NewsDataSource();

La respuesta es sencilla. Pasar Client en el constructor nos permite pasar un Mock que nos permitirá hacer pruebas unitarias a la clase NewsDataSource.

Ahora vamos a crear una función de ayuda que es responsable de hacer peticiones a las API y regresar un objeto de tipo ApiResponse con la respuesta:

lib/src/data_source/news_data_source.dart
  Future<ApiResponse> _callGetApi({
required String endpoint,
required Map<String, String> params,
}) async {

// Creamos la URI con la que hacemos una petición a la API
final uri = Uri.https(baseUrl, endpoint, params);
final response = await _httpClient.get(uri);

// Usamos `json.decode` para convertir el 'body' de la respuesta a un objeto Json
final result = ApiResponse.fromJson(json.decode(response.body));

// Si la respuesta contiene un error, lanzamos una excepción
if (result.status == 'error') {
if (result.code == 'apiKeyMissing') throw MissingApiKeyException();
if (result.code == 'apiKeyInvalid') throw ApiKeyInvalidException();
throw Exception();
}

// Si no hay ningún error, regresamos el resultado de la API
return result;
}

// Definimos algunas excepciones personalizadas que podemos
// manejar en la capa de la aplicación.
class MissingApiKeyException implements Exception {}
class ApiKeyInvalidException implements Exception {}

La función _callGetApi será llamada siempre que hagamos una petición a cualquiera de las API. Los códigos de error los podemos encontrar en la documentación de News API. Para este ejemplo, solo creamos excepciones para apiKeyMissing y apiKeyInvalid.

Ahora vamos a crear dos funciones que nos ayudarán a llamar las API que nos interesan:

lib/src/data_source/news_data_source.dart
  // Realiza una petición a /v2/top-headline. Recibe el país
Future<List<Article>> fetchTopHeadlines({
required String country,
}) async {
final params = {
'apiKey': apiKey,
'country': country,
};

final result = await _callGetApi(
endpoint: topHeadlines,
params: params,
);
return result.articles!;
}

// Realiza una petición a /v2/everything. Recibe el idioma y un texto de búsqueda
Future<List<Article>> fetchEverything({
required String language,
String? search,
}) async {
final params = {
'apiKey': apiKey,
'language': language,
};

if (search != null) params['q'] = search;

final result = await _callGetApi(
endpoint: everything,
params: params,
);
return result.articles!;
}

Hemos creado dos funciones, fetchTopHeadlines y fetchEverything, cada una con sus respectivos parámetros, y cada una de ellas construye los parámetros params de acuerdo a lo que requiere cada API.

Ya hemos completado todas las funciones, variables, etc. que conforman la clase NewsDataSource. Si ponemos todo junto, el código será:

lib/src/data_source/news_data_source.dart
class MissingApiKeyException implements Exception {}
class ApiKeyInvalidException implements Exception {}

class NewsDataSource {
// Visita la página de https://newsapi.org/ para obtener tu API key
static const String apiKey = 'xxxxxxxxxxxxxxxxxxxxxxxxx';

static const String baseUrl = 'newsapi.org';
static const String everything = '/v2/everything';
static const String topHeadlines = '/v2/top-headlines';

final Client _httpClient;

NewsDataSource({Client? httpClient}) : _httpClient = httpClient ?? Client();

Future<List<Article>> fetchTopHeadlines({
required String country,
}) async {
final params = {
'apiKey': apiKey,
'country': country,
};

final result = await _callGetApi(
endpoint: topHeadlines,
params: params,
);
return result.articles!;
}

Future<List<Article>> fetchEverything({
required String language,
String? search,
}) async {
final params = {
'apiKey': apiKey,
'language': language,
};

if (search != null) params['q'] = search;

final result = await _callGetApi(
endpoint: everything,
params: params,
);
return result.articles!;
}

Future<ApiResponse> _callGetApi({
required String endpoint,
required Map<String, String> params,
}) async {
final uri = Uri.https(baseUrl, endpoint, params);

final response = await _httpClient.get(uri);
final result = ApiResponse.fromJson(json.decode(response.body));

if (result.status == 'error') {
if (result.code == 'apiKeyMissing') throw MissingApiKeyException();
if (result.code == 'apiKeyInvalid') throw ApiKeyInvalidException();
throw Exception();
}

return result;
}
}

Pruebas Unitarias a NewsDataSource

Ahora vamos a realizar pruebas en la clase NewsDataSource. Para ello, utilizaremos el paquete Mockito, que sirve para crear "mocks" y simular peticiones exitosas y fallidas a las API.

Crearemos tres archivos .json que contendrán las respuestas simuladas de la API. El primer archivo contiene una respuesta fallida con el error apiKeyInvalid. Con este archivo, probaremos si la excepción ApiKeyInvalidException se lanza correctamente:

test/data_source_test/api_key_invalid.json
{
"status": "error",
"code": "apiKeyInvalid",
"message": "Your API key is invalid or incorrect. Check your key, or go to https://newsapi.org to create a free API key."
}

El segundo archivo contiene el error apiKeyMissing, y con él, probaremos si la excepción MissingApiKeyException se lanza correctamente:

test/data_source_test/api_key_missing.json
{
"status": "error",
"code": "apiKeyMissing",
"message": "Your API key is missing. Append this to the URL with the apiKey param, or use the x-api-key HTTP header."
}

El tercer archivo simula la respuesta exitosa de la API y contiene dos noticias:

test/data_source_test/success_response.json
{
"status": "ok",
"totalResults": 2,
"articles": [
{
"source": {
"id": "cbs-news",
"name": "CBS News"
},
"author": "Sophie Lewis",
"title": "\"Bachelor\" host Chris Harrison is temporarily \"stepping aside\" after \"excusing historical racism\" - CBS News",
"description": "\"By excusing historical racism, I defended it,\" Harrison wrote, announcing he will not host the \"After the Final Rose\" live special.",
"url": "https://www.cbsnews.com/news/chris-harrison-the-bachelor-stepping-aside-comments-rachel-lindsay-backlash/",
"urlToImage": "https://cbsnews1.cbsistatic.com/hub/i/r/2021/02/12/0cf2794b-2b04-4ced-84ce-1d72e26ed2cb/thumbnail/1200x630/8d464cc0fdf23566bbea2e01c41401ab/gettyimages-1204035255.jpg",
"publishedAt": "2021-02-14T12:18:00Z",
"content": "Chris Harrison, the longtime host of \"The Bachelor\" and \"The Bachelorette,\" announced Saturday that he is \"stepping aside\" from the franchise for an undisclosed \"period of time.\" The bombshell announ… [+7754 chars]"
},
{
"source": {
"id": null,
"name": "KOCO Oklahoma City"
},
"author": "KOCO Staff",
"title": "Winter storm brings heavy snow, causing hazardous driving conditions across Oklahoma - KOCO Oklahoma City",
"description": "A winter storm moving across Oklahoma Sunday morning is dumping heavy snow, causing hazardous driving conditions.",
"url": "https://www.koco.com/article/winter-storm-brings-heavy-snow-causing-hazardous-driving-conditions-across-oklahoma/35500508",
"urlToImage": "https://kubrick.htvapps.com/htv-prod-media.s3.amazonaws.com/images/poster-image-2021-02-14t060232-115-1613304161.jpg?crop=1.00xw:1.00xh;0,0&resize=1200:*",
"publishedAt": "2021-02-14T12:17:00Z",
"content": "OKLAHOMA CITY —A winter storm moving across Oklahoma Sunday morning is dumping heavy snow, causing hazardous driving conditions. \r\n[Check latest weather alerts in your area | Check live traffic condi… [+2381 chars]"
}
]
}

Vamos a crear un archivo llamado data_source_test.dart dentro de la carpeta de test y establecemos la estructura básica:

test/data_source_test/data_source_test.dart
// Mocks que nos permitirán simular peticiones exitosas y fallidas
class MockClient extends Mock implements Client {}
class FakeUri extends Fake implements Uri {}

// Ruta donde están los archivos .json
const mockPath = 'test/data_source_test';

void main() {
late MockClient mockClient;
late NewsDataSource dataSource;

setUpAll(() {
registerFallbackValue(FakeUri());
});

// setUp se llama antes de cada prueba.
setUp(() {
mockClient = MockClient();

// Creamos un objeto NewsDataSource y le pasamos el mockClient
dataSource = NewsDataSource(httpClient: mockClient);
});

// Agrupamos las pruebas de la función fetchEverything
group('When calling fetchEverything', () {
// Aquí escribiremos las pruebas de este grupo
});

// Agrupamos las pruebas de la función fetchTopHeadlines
group('When calling fetchTopHeadlines', () {
// Aquí escribiremos las pruebas de este grupo
});
}

// Función de ayuda para leer los archivos .json como Strings.
Future<String> getMockBody(String filePath) => File(filePath).readAsString();
note

En este artículo solo vamos a hacer pruebas a la función fetchEverything, y como tarea, puedes agregar las pruebas a la función fetchTopHeadlines.

La primera prueba que vamos a hacer es verificar que llamar a la API sea exitosa, y como el archivo JSON que creamos para hacer pruebas exitosas contiene dos noticias, podemos verificar que al llamar a la función fetchEverything, contenga dos noticias:

test/data_source_test/data_source_test.dart
    test('The response contains two news', () async {
// Obtenemos el string del JSON con la respuesta exitosa
final response = await getMockBody('$mockPath/success_response.json');

// Le decimos al mockClient que cuando reciba una petición GET
// regrese el JSON con la respuesta exitosa y el estatus 200.
when(() => mockClient.get(any())).thenAnswer((_) async {
return Response(response, 200, headers: headers);
});

// Llamamos la función fetchEverything
final articles = await dataSource.fetchEverything(language: 'es');

// El resultado esperado son dos noticias,
// cada una con un autor diferente
expect(articles.length, 2);
expect(articles[0].author, 'Sophie Lewis');
expect(articles[1].author, 'KOCO Staff');
});

En la prueba anterior, verificamos que llamar a la función fetchEverything regrese el resultado esperado. En la siguiente prueba, vamos a verificar que al llamar la función fetchEverything con los argumentos language:'es' y search:'Hello world', él Client reciba los argumentos exitosamente:

test/data_source_test/data_source_test.dart
    test('The request contains the expected parameters', () async {
// Obtenemos el contenido JSON de la respuesta exitosa
final response = await getMockBody('$mockPath/success_response.json');

// Configuramos el mockClient para que, al recibir una petición GET,
// retorne el JSON de la respuesta exitosa y un estado 200.
when(() => mockClient.get(any())).thenAnswer((_) async {
return Response(response, 200, headers: headers);
});

// Argumentos que debe recibir el Client
const language = 'es';
const searchTerm = 'Hello world';

// Llamamos a la función fetchEverything
await dataSource.fetchEverything(
language: language,
search: searchTerm,
);

// Creamos la Uri esperada con los argumentos proporcionados
final uri = Uri.https(
NewsDataSource.baseUrl,
NewsDataSource.everything,
{
'apiKey': NewsDataSource.apiKey,
'language': language,
'q': searchTerm,
},
);

// Verificamos que el Client fue llamado con la Uri esperada
verify(() => mockClient.get(uri)).called(1);
});

Ahora vamos a crear la prueba donde la respuesta es fallida y la función lanza la excepción MissingApiKeyException:

test/data_source_test/data_source_test.dart
    test('Correctly throws Missing API Key Exception for non-successful response', () async {
// Obtenemos el contenido JSON de la respuesta fallida
final response = await getMockBody('$mockPath/api_key_missing.json');

// Configuramos el mockClient para que, al recibir una petición GET,
// retorne el JSON de la respuesta fallida y un estado 200.
when(() => mockClient.get(any())).thenAnswer((_) async {
return Response(response, 200);
});

// Al llamar la función fetchEverything, esperamos que
// la excepción MissingApiKeyException sea lanzada
expect(
() => dataSource.fetchEverything(language: 'es'),
throwsA(
predicate((exception) => exception is MissingApiKeyException)));
});

Y por último la prueba donde la respuesta es fallida y la función lanza la excepción ApiKeyInvalidException:

test/data_source_test/data_source_test.dart
    test('Correctly throws Invalid API Key Exception for non-successful response', () async {
// Obtenemos el contenido JSON de la respuesta fallida
final response = await getMockBody('$mockPath/api_key_invalid.json');

// Configuramos el mockClient para que, al recibir una petición GET,
// retorne el JSON de la respuesta fallida y un estado 200.
when(() => mockClient.get(any())).thenAnswer((_) async {
return Response(response, 200);
});

// Al llamar la función fetchEverything, esperamos que
// la excepción ApiKeyInvalidException sea lanzada
expect(
() => dataSource.fetchEverything(language: 'es'),
throwsA(
predicate((exception) => exception is ApiKeyInvalidException)));
});

Muy bien, hemos terminado la clase NewsDataSource y también hemos agregado pruebas para asegurarnos de que funcione como esperamos.

Inyección de dependencias

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:

lib/src/dependency_injection.dart
final getIt = GetIt.instance;

Future<void> injectDependencies() async {
getIt.registerLazySingleton(() => NewsDataSource());
}

Luego, en la función main(), llamamos a injectDependencies() para registrar las dependencias:

lib/main.dart
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.

note

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:

lib/src/repository/news_repository.dart
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 aprendimos a utilizar la inyección de dependencias con GetIt, lo que nos brinda flexibilidad y facilidad de prueba.

Capa de la aplicación: Lógica de negocios

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í.

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.

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 número 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:

lib/src/bloc/news_cubit.dart
// Todos los estados heredan de la clase NewsState
sealed class NewsState extends Equatable {

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);


List<Object> get props => [news];
}

// Estado de error
class NewsErrorState extends NewsState {

// Contiene el mensaje de error
final String message;

NewsErrorState(this.message);


List<Object> get props => [message];
}

Ya hemos definido los estados, así que ahora podemos crear la clase NewsCubit de la siguiente manera:

lib/src/bloc/news_cubit.dart
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

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:

  1. Barra de búsqueda: El usuario puede introducir texto para buscar noticias de su interés.
  2. Selección de idioma: Permite al usuario cambiar el idioma de la aplicación.
  3. Botón borrar: Borra el contenido de la barra de búsqueda y muestra en la aplicación las últimas 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.

lib/src/bloc/news_cubit.dart
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():

lib/src/bloc/news_cubit.dart
  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á:

lib/src/bloc/news_cubit.dart
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 Unitarias a 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:

test/bloc_test/news_cubit_test.dart
// 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:

test/bloc_test/news_cubit_test.dart
  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:

test/bloc_test/news_cubit_test.dart
  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:

test/bloc_test/news_cubit_test.dart
  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 la 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:

test/bloc_test/news_cubit_test.dart
  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:

test/bloc_test/news_cubit_test.dart
  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.

Capa de presentación: La interfaz de usuario

La interfaz de usuario

Como mencionamos antes, la capa de presentación se encarga de las interacciones y de lo que el usuario puede ver: botones, imágenes, texto, etc.

Tenemos que crear una aplicación con soporte para tres tamaños de pantalla diferentes:

App responsiva con soporte para tres tamaños.

App responsiva con soporte para tres tamaños.

Vista de escritorio

Comenzaremos con el tamaño de pantalla más grande que sería para una computadora de escritorio. Vamos a descomponer la interfaz de usuario en diferentes partes:

Podemos identificar tres partes principales:

  1. Barra de búsqueda: El usuario puede introducir texto para buscar noticias de su interés.

  2. Lista de noticias: Se compone de varias noticias; al dar clic a una de ellas, veremos los detalles de la noticia.

  3. Detalles de la noticia: El usuario puede ver los detalles de la noticia, y al dar clic en "ver más", se abre el navegador con la página de la noticia.

Cada una de estas partes se puede descomponer en partes aún más pequeñas, pero por el momento vamos a trabajar con estas tres. Para la barra de búsqueda, vamos a crear un widget llamado SearchWidget. Para los detalles de las noticias, creamos un widget llamado DetailsWidget, y para cada ítem del listado de noticias, el widget se llamará ListItem.

Primero creamos el SearchWidget:

lib/src/ui/molecules/search_widget.dart
// En lugar de usar un StatefulWidget, usamos Flutter Hooks.
class SearchWidget extends HookWidget {
const SearchWidget({
super.key,
required this.onChanged,
required this.onClearPress,
});

// "Callback" que se ejecuta cuando presionamos el botón de borrar en la barra de búsqueda.
final VoidCallback onClearPress;

// "Callback" que se ejecuta cada vez que el término de búsqueda cambia.
final ValueChanged<String> onChanged;


Widget build(BuildContext context) {
// Creamos un TextEditingController con Flutter Hooks.
final controller = useTextEditingController();

// Estado que nos ayuda a mostrar u ocultar el botón de borrar
// si en la barra de búsqueda hay texto o no.
final searchTermEmpty = useState(controller.text.isEmpty);

return Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
controller: controller,
onChanged: (text) {

// Retrasamos la llamada a "onChanged" para evitar múltiples llamadas a la API.
EasyDebounce.debounce(
'search-news',
const Duration(milliseconds: 300),
() => onChanged.call(text),
);
searchTermEmpty.value = controller.text.isEmpty;
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: LocaleKeys.search_news.tr(),

// Ocultamos o mostramos el botón de borrar si hay texto o no.
suffixIcon: searchTermEmpty.value
? null
: IconButton(
onPressed: () {
controller.clear();
onClearPress.call();
},
icon: const Icon(Icons.clear),
),
border: const OutlineInputBorder(),
),
),
);
}
}
info

En el widget SearchWidget hemos utilizado los siguientes paquetes:

  • Flutter Hooks: Nos ayuda a eliminar código repetitivo y lo hace más fácil de leer. Aprende más sobre él en este videotutorial.
  • EasyDebounce: Este paquete evita hacer llamadas excesivas a la API cuando el usuario escribe en la barra de búsqueda.

Ahora procederemos a crear el DetailsWidget:

lib/src/ui/molecules/details_widget.dart
class DetailsWidget extends StatelessWidget {

// En el constructor, inicializamos la noticia de la cual mostraremos los detalles.
const DetailsWidget({
Key? key,
required this.article,
});

// La noticia de la que mostraremos los detalles.
final Article article;


Widget build(BuildContext context) {

// Usamos `double.infinity` en la altura del `SizedBox` para ocupar toda la altura disponible.
return SizedBox(
height: double.infinity,
child: SingleChildScrollView(
child: Column(
children: [
// Si la noticia no tiene imagen, mostramos un contenedor de color rojo;
// de lo contrario, utilizamos `CachedNetworkImage` para mostrarla.
article.urlToImage == null
? Container(color: Colors.red, height: 250)
: CachedNetworkImage(
width: 450,
imageUrl: article.urlToImage!,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) =>
const Icon(Icons.error),
),
Text(
article.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
const SizedBox(height: 8),
Text('${article.content}'),
const SizedBox(height: 8),

// Al presionar el botón, abriremos la noticia en el navegador.
ElevatedButton(
onPressed: () => launchUrlString(article.url),
child: const Text('Ver más'),
),
const SizedBox(height: 16),
],
),
),
);
}
}

Ahora vamos a crear el widget ListItem:

lib/src/ui/molecules/list_item.dart
// Constante de ayuda que define el ancho y la altura de la imagen
const _imageSize = 120.0;

class ListItem extends StatelessWidget {

// En el constructor, inicializamos la noticia de la que mostraremos los detalles.
const ListItem({
Key? key,
required this.article,
required this.onTap,
});

// La noticia de la que mostraremos los detalles.
final Article article;

// Función de devolución de llamada que se ejecuta cuando se toca este widget.
final GestureTapCallback onTap;


Widget build(BuildContext context) {
// GestureDetector nos ayuda a detectar si este widget ha sido presionado.
return GestureDetector(
onTap: onTap,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// `_Image` es un widget privado que nos ayuda a mostrar la imagen o
// mostrar un contenedor vacío si la noticia no tiene imagen.
_Image(url: article.urlToImage),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Mostramos el título de la noticia.
Text(
article.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
// Si la noticia tiene descripción, la mostramos.
if (article.description != null) ...[
const SizedBox(height: 16),
Text(
article.description!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
]
],
),
),
),
],
),
),
);
}
}

// `_Image` es un widget privado que nos ayuda a mostrar la imagen de la noticia.
class _Image extends StatelessWidget {

const _Image({
required this.url,
});

final String? url;


Widget build(BuildContext context) {

// Si la URL es nula, mostramos un contenedor de color rojo.
return url == null
? Container(
width: _imageSize,
height: _imageSize,
color: Colors.red,
)

// Si la URL no es nula, utilizamos `CachedNetworkImage` para mostrar la imagen.
: CachedNetworkImage(
width: _imageSize,
height: _imageSize,
imageUrl: url!,
fit: BoxFit.cover,

// Mientras la imagen se carga, mostramos un CircularProgressIndicator.
placeholder: (context, url) => const SizedBox(
width: _imageSize,
height: _imageSize,
child: Center(
child: CircularProgressIndicator(),
),
),

// En caso de algún error, mostramos el icono de error.
errorWidget: (context, url, error) => const Icon(Icons.error),
);
}
}

Ya hemos construido las tres partes principales: la barra de búsqueda SearchWidget, los detalles de la noticia DetailsWidget, y el widget que representa cada una de las noticias en la lista de noticias ListItem. El siguiente paso es integrar NewsCubit con la interfaz de usuario.

Integrar NewsCubit con la interfaz de usuario

Vamos a crear la pantalla de inicio, que se mostrará cuando los usuarios abran la aplicación. La llamaremos NewsScreen.

lib/src/ui/news_screen.dart
class NewsScreen extends StatelessWidget {
const NewsScreen({Key? key});


Widget build(BuildContext context) {

// Obtenemos el "locale" actual de la aplicación.
final locale = Localizations.localeOf(context);

// Usamos BlocProvider para crear un NewsCubit y agregarlo al árbol de widgets.
return BlocProvider<NewsCubit>(

// Creamos un NewsCubit con el "locale" actual y llamamos la función searchNews
// para iniciar la búsqueda en cuanto la pantalla NewsScreen sea visible.
create: (context) => NewsCubit(locale)..searchNews(),
child: Scaffold(
appBar: AppBar(
title: Text('Aplicación de noticias'),
),

// Usamos BlocBuilder para actuar de acuerdo con los estados de NewsCubit.
body: BlocBuilder<NewsCubit, NewsState>(
builder: (context, state) {

// Dependiendo del estado, vamos a devolver diferentes widgets.
return ?????;

},
),
),
);
}
}

Ahora podemos mostrar la pantalla NewsScreen en cuanto la aplicación sea lanzada de la siguiente forma:

lib/main.dart
void main() async {

// Inyectamos las dependencias al iniciar la aplicación.
injectDependencies();

runApp(

// Creamos un MaterialApp que muestra la pantalla NewsScreen.
const MaterialApp(
title: 'Flutter Demo',
home: NewsScreen(),
),

);
}

Dependiendo del estado actual del cubit, debemos devolver un widget diferente en la función builder del BlocBuilder:

lib/src/ui/news_screen.dart
    BlocBuilder<NewsCubit, NewsState>(
builder: (context, state) {

// Usamos una columna para alinear los widgets.
return Column(
children: [

// El primer widget es la barra de búsqueda.
SearchWidget(
// Cuando el texto cambia, si es mayor de tres caracteres
// llamamos searchNews para realizar una nueva búsqueda.
onChanged: (text) {
context
.read<NewsCubit>()
.searchNews(search: text.length > 3 ? text : null);
},

// Cuando presionamos el botón de borrar, llamamos clearSearch para hacer una nueva búsqueda sin término de búsqueda.
onClearPress: context
.read<NewsCubit>()
.clearSearch,
),

// Luego tenemos un "Expanded" porque el contenido después de la
// barra de búsqueda ocupará el espacio restante.
Expanded(
// El "Builder" no es necesario, pero ayuda a usar "if else" de forma más organizada.
child: Builder(
builder: (context) {
if (state is NewsSuccessState) {
// Si el estado es exitoso, mostramos el widget _BigSize.
return _BigSize(news: state.news);

} else if (state is NewsErrorState) {

// Si el estado es de error, mostramos un texto con el error.
return Text(state.message);

} else {

// Si el estado es de carga, mostramos un indicador de carga.
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
),
],
);
},
)

Muy bien, hemos implementado el contenido del BlocBuilder, pero hay un widget que no hemos creado aún. _BigSize es el widget que contiene la estructura de la interfaz de usuario para pantallas grandes. Veamos como implementarlo:

lib/src/ui/news_screen.dart
class _BigSize extends HookWidget {

// En el constructor inicializamos la lista de noticias
const _BigSize({required this.news});

final List<Article> news;


Widget build(BuildContext context) {

// Usamos hooks para mantener el estado de la noticia seleccionada
final currentNew = useState(news.firstOrNull);


// Usamos Row para separar la pantalla en dos. El listado de noticias
// del lado izquierdo y los detalles de la noticia del derecho
return Row(
children: [
// El listado de noticias tiene un ancho fijo de 450
SizedBox(
width: 450,
child: ListView.builder(
itemCount: news.length,

// Al presionar un item de la lista actualizamos el estado local con la noticia seleccionada
itemBuilder: (_, int index) => ListItem(
article: news[index],
onTap: () => currentNew.value = news[index],
),
),
),
// Mostramos los detalles de la noticia seleccionada
if (currentNew.value != null)
Expanded(
child: DetailsWidget(
article: currentNew.value!,
),
),
],
);
}
}

Ahora podemos correr la aplicación y ver el resultado.

Aplicación sin soporte para Tabletas o iPads ni teléfonos celulares

Aplicación sin soporte para Tabletas o iPads ni teléfonos celulares

La aplicación se ve bien, podemos seleccionar diferentes noticias, pero el problema es que la aplicación no se adapta a los cambios de pantalla y muestra errores de desbordamiento. Continuemos agregando soporte para otros tamaños de pantalla.

Vista de Tablet o iPad

Es hora de agregar soporte para tabletas y iPad, en este caso el listado de noticias y los detalles van a estar en pantallas diferentes como podemos ver en la siguiente imagen

1. Al presionar una noticia del listado navegamos a los detalles de la noticia

1. Al presionar una noticia del listado navegamos a los detalles de la noticia

Vamos a crear una pantalla nueva que llamaré NewsDetailScreen y nos servirá para ver los detalles de la noticia:

lib/src/ui/news_detail_screen.dart
// Definimos un tipo que usaremos para pasar la noticia
// como argumento durante la navegación
typedef NewsDetailScreenArgs = ({Article article});

class NewsDetailScreen extends StatelessWidget {
const NewsDetailScreen({super.key});


Widget build(BuildContext context) {
// Obtenemos la noticia
final article = context.args<NewsDetailScreenArgs>().article;

// Creamos un Scaffold con un AppBar y usamos el widget DetailsWidget
// para mostrar los detalles de la noticia
return Scaffold(
appBar: AppBar(
title: const Text('News Details'),
),
body: DetailsWidget(article: article),
);
}
}

En el código anterior podemos ver que la clase NewsDetailScreen es muy sencilla, ya que estamos reutilizando el widget DetailsWidget.

Para la navegación vamos a usar rutas por lo que vamos a crear una nueva clase llamada Routes que se encargara de administrar las rutas.

lib/src/navigation/routes.dart
class Routes {
// Ruta raiz
static const newsScreen = '/';

// Ruta de detalles de la noticia
static const detailsScreen = '/details';

static Route routes(RouteSettings settings) {

// Función de ayuda para crear MaterialPageRoute y no
// tener mucho código repetitivo
MaterialPageRoute buildRoute(Widget widget) {
return MaterialPageRoute(builder: (_) => widget, settings: settings);
}

// Usamos un switch para navegar a la ruta deseada, si la ruta
// no existe lanzamos una excepción
return switch (settings.name) {
newsScreen => buildRoute(const NewsScreen()),
detailsScreen => buildRoute(const NewsDetailScreen()),
_ => throw Exception('Route does not exists'),
};
}
}

También tenemos que modificar MaterialApp para usar onGenerateRoute en lugar de home.

lib/src/app.dart
    return MaterialApp(
title: 'Flutter Demo',
onGenerateRoute: Routes.routes,
);

Muy bien, ya tenemos lista la navegación, pero también tenemos que agregar el soporte para tabletas e iPads, vamos a crear un nuevo widget privado que se encargara de mostrar el listado en pantallas medianas:

lib/src/ui/news_screen.dart
class _MediumSize extends StatelessWidget {

// En el constructor inicializamos la lista de noticias
const _MediumSize({required this.news});

final List<Article> news;


Widget build(BuildContext context) {

// Creamos el listado de noticias
return ListView.builder(
itemCount: news.length,
itemBuilder: (_, int index) => ListItem(
article: news[index],
onTap: () {
// Al presionar un ítem de la lista vamos a
// navegar a la pantalla de detalles
final args = (article: news[index]);
Navigator.pushNamed(context, Routes.detailsScreen, arguments: args);
},
),
);
}
}

Y por último para decidir si mostrar el widget _BigSize o el widget _MediumSize vamos a utilizar un LayoutBuilder de la siguiente forma:

lib/src/ui/news_screen.dart
    return LayoutBuilder(
builder: (context, constraints) {
// Si el ancho máximo disponible es menor de 900 unidades entonces
// mostraremos _MediumSize de lo contrario mostramos _BigSize
return switch (constraints.maxWidth) {
< 900 => _MediumSize(news: state.news),
_ => _BigSize(news: state.news),
};
},
);

Si corremos la aplicación y cambiamos el tamaño de la pantalla podemos ver que la interfaz de usuario se adapta correctamente.

Vista de Celular/Móvil

Es hora de agregar soporte para teléfonos celulares. Pero esta parte es una tarea para el lector así que vamos como es la interfaz de usuario:

1. Al presionar una noticia del listado navegamos a los detalles de la noticia.

1. Al presionar una noticia del listado navegamos a los detalles de la noticia.

La interfaz de usuario es muy parecida a la interfaz de usuario para tabletas así que no será muy difícil de crear, pero aquí te dejo unos consejos:

  • Hay que crear un nuevo widget para representar cada noticia del listado de noticias

  • Hay que crear un nuevo widget para mostrar el listado de noticias. Para tener consistencia en el código yo lo llamaría _SmallSize

  • Hay que modificar la lógica del LayoutBuilder para mostrar el widget _SmallSize

  • NO no hay que crear ninguna pantalla ni rutas nuevas.

Si tienes algún problema recuerda que el código fuente lo puedes descargar de Github.

Soporte de múltiples idiomas

Usando EasyLocalization

Ahora vamos a agregar soporte para múltiples idiomas a la aplicación. Utilizaremos el paquete de EasyLocalization que simplifica el proceso de agregar múltiples idiomas. Aprende más de este paquete en este videotutorial.

Primero tenemos que decidir que idiomas queremos soportar, podemos ver la documentación de las dos APIS que vamos a soportar:

  1. /v2/everything: Soporta el parámetro language con valores como es, en, zh y muchos más.
  2. /v2/top-headlines: Soporta el parámetro country con valores como mx, ve, us, tw y muchos más.

En esta aplicación solo vamos a agregar algunos idiomas y países. Comenzamos creando una lista de "Locales" soportados:

lib/src/localization/supported_locales.dart
const spanishMx = Locale('es', 'MX'); // Español de México
const spanishVe = Locale('es', 'VE'); // Español de Venezuela
const englishUs = Locale('en', 'US'); // Ingles de EUA
const chineseTw = Locale('zh', 'TW'); // Chino de Taiwán

// Mapa con el nombre de los idiomas soportados
final supportedLocales = <Locale, String>{
englishUs: 'English - US',
spanishMx: 'Español - MX',
spanishVe: 'Español - VE',
chineseTw: '中文 - TW',
};

También necesitamos tres archivos JSON con las traducciones de los idiomas soportados.

Para inglés tenemos en.json

resources/translations/en.json
{
"top_headlines": "Top Headlines",
"search_news": "Search News",
"view_more": "View more"
}

Para español tenemos es.json

resources/translations/es.json
{
"top_headlines": "Titulares Principales",
"search_news": "Buscar Noticias",
"view_more": "Ver más"
}

Y para el chino tenemos zh.json

resources/translations/zh.json
{
"top_headlines": "最新的新聞",
"search_news": "找新聞",
"view_more": "看更多"
}

EasyLocalization soporta generación de código que crea cada una de las llaves en un mapa y es más fácil acceder a ellas desde el código. Por ejemplo:

// Sin generación de código podemos comente errores como el siguiente:
Text('top_headLines'.tr());

// ¿Encontraste el error? En vez de escribir 'top_headlines' escribimos 'top_headLines'.

// Pero con generación de código no podemos cometer ningún error:
Text(LocaleKeys.top_headlines.tr())

Vamos a correr el siguiente comando para generar todo el código necesario:

flutter pub run easy_localization:generate -f keys -S resources/translations -O lib/src/localization -o locale_keys.g.dart
flutter pub run easy_localization:generate -S resources/translations -O lib/src/localization
note

No te preocupes si no entiendes el comando anterior. En el código fuente puedes encontrar el archivo generate_code.sh y ejecutarlo desde la terminal o consola y generará todo el código necesario.

Cuando ejecutemos el comando anterior, se van a generar los archivos codegen_loader.g.dart y locale_keys.g.dart dentro de la carpeta lib/src/localization.

Después de generar el código necesario vamos a inicializar el paquete EasyLocalization para esto vamos a modificar la función main() de la siguiente manera:

lib/main.dart
void main() async {

// Nos aseguramos de inicializar WidgetsFlutterBinding y EasyLocalization
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();

injectDependencies();
runApp(const MyApp());
}

También tenemos que configurar EasyLocalization y encerrar nuestro MaterialApp con el widget EasyLocalization:

lib/src/app.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return EasyLocalization(
// El idioma de respaldo es el inglés de EUA
fallbackLocale: englishUs,
useFallbackTranslations: true,

// Pasamos la lista de los idiomas que soportamos
supportedLocales: supportedLocales.keys.toList(),

// path no es necesario porque estamos usando código generado
path: 'xxx',

// Pasamos la clase generada
assetLoader: const CodegenLoader(),

// Encerramos MaterialApp con el widget EasyLocalization
child: const _MaterialApp(),
);
}
}

class _MaterialApp extends StatelessWidget {
const _MaterialApp();


Widget build(BuildContext context) {
// Configuramos MaterialApp para soportar diferentes idiomas
return MaterialApp(
title: 'Flutter Demo',
locale: context.locale,
onGenerateRoute: Routes.routes,
debugShowCheckedModeBanner: false,
supportedLocales: context.supportedLocales,
localizationsDelegates: context.localizationDelegates,
);
}
}

Ahora vamos a regresar a la clase NewsScreen y hacer dos modificaciones, la primera modificación es usar la extensión de EasyLocalization para obtener el locale actual. Así que vamos a reemplazar el siguiente código:

lib/src/ui/news_screen.dart
// Reemplazamos esta línea de código
final locale = Localizations.localeOf(context);

// por
final locale = context.locale;

La diferencia en el código anterior es que usar la extensión de EasyLocalization es mucho más corto.

También tenemos que agregar un PopupMenuButton que nos permita seleccionar entre los idiomas que soporta nuestra aplicación:

PopupMenuButton para seleccionar el idioma.

PopupMenuButton para seleccionar el idioma.

Vamos a crear un widget llamado _LanguagePopUpMenu que nos permitirá mostrar un menú para poder seleccionar entre los idiomas soportados:

lib/src/ui/news_screen.dart
class _LanguagePopUpMenu extends StatelessWidget {

Widget build(BuildContext context) {
// Creamos un PopupMenuButton con el icono de traducir
return PopupMenuButton<Locale>(
icon: const Icon(Icons.translate),
itemBuilder: (context) {
// Iteramos cada uno de los idiomas soportados para crear
// una lista de PopupMenuItem
return supportedLocales.entries.map((it) {
return PopupMenuItem(
value: it.key,
child: Text(it.value),
);
}).toList();
},
// Cada vez que seleccionamos un idioma del meno actualizamos
// el idioma de la aplicación y del cubit.
onSelected: (selectedLocale) {
context.setLocale(selectedLocale);
context.read<NewsCubit>().setLocale(selectedLocale);
},
);
}
}

Después lo agregaremos al AppBar en la propiedad de actions:

lib/src/ui/news_screen.dart
 // Agregamos el widget _LanguagePopUpMenu al AppBar
appBar: AppBar(
title: Text(LocaleKeys.top_headlines.tr()),
actions: [
_LanguagePopUpMenu(),
],
),

Ahora podemos correr la aplicación y tendremos la opción de seleccionar el idioma lo que hace cambiar el texto de los componentes de la interfaz de usuario y además podremos hacer búsquedas en cualquiera de los idiomas soportados.

Con esto finalizamos esta serie de artículos así que recuerda que puedes encontrar el código fuente en GitHub.