Cubit en Práctica: Creando una Aplicación de Noticias Responsiva y con Soporte de Múltiples Idiomas. Parte 1

En esta serie de artículos, 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.

Puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:

Propósito de este tutorial:

  • Crear una app responsiva con soporte para dispositivos pequeños, medianos y grandes.

  • Aprender a usar cubit como manejador 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, APIs 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.

Nota

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 APIs, 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:

@JsonSerializable()
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);

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

Nota

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:

@JsonSerializable()
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);

  @override
  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.

Recuerda

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

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.

Fuente de datos: NewsDataSource

La clase NewsDataSource se encargará de hacer peticiones a las APIs 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:

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

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 APIs y regresar un objeto de tipo ApiResponse con la respuesta:

  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 APIs. 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 APIs que nos interesan:

  // 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á:

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 a la clase 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 APIs.

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:

{
  "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:

{
  "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:

{
  "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:

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

Importante

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('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', el Client reciba los argumentos exitosamente:

    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('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('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.

En el siguiente artículo veremos cómo usar inyección de dependencias para inicializar la clase NewsDataSource para que pueda ser usada por el repositorio.

Recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:

Comparte este artículo