Cubit in Action: Creating a Responsive News Application with Multi-Language Support. Part 1

In this series of articles, we will create a responsive news application supporting three languages: Spanish, English, and Chinese. Additionally, we will implement a scalable architecture and add unit tests and widget tests. The application will display the latest headlines and provide the option to search for news of interest and select your preferred language.

Responsive news app supporting three different sizes.

Responsive news app supporting three different sizes.

You can find the source code on GitHub. Also, don't forget to check out the other articles in this tutorial series:

Purpose of this Tutorial:

  • Create a responsive app supporting small, medium, and large devices.

  • Learn to use cubit as a state manager.

  • Establish a simple, scalable architecture allowing for testing.

  • Learn how to add unit tests and widget tests to our project.

  • Make REST API requests using http.

  • Apply dependency injection with GetIt (not to be confused with GetX).

Prerequisites (Optional):

Application Architecture

Defining an architecture is crucial when creating an application as it offers numerous benefits, such as:

  • Maintainability: A solid architecture facilitates error detection and correction, as well as the addition of new updates and features.

  • Team Collaboration: A well-defined architecture guides developers, reducing conflicts as everyone understands the project's structure.

  • Code Quality: With architecture, we can define standards and best practices that the entire team must follow, ensuring consistency and quality in the project.

  • Code Reusability: A good architecture helps abstract code sections that can be reused in different project parts.

  • Testable Code: A good architecture allows us to separate different application parts into sections with specific responsibilities, making it easier to test each section in isolation without depending on others.

There are many other important reasons for having an architecture, but that can be an article of its own. In a few words, defining an architecture helps us create maintainable, scalable, testable code that promotes team collaboration.

The architecture of this project is very similar to what can be found in the Flutter Bloc documentation.

Architecture: data sources, repositories, business logic, and presentation layer.

Architecture: data sources, repositories, business logic, and presentation layer.

As seen in the image above, the architecture is divided into three parts: external data sources, the data layer, and the application layer. What is the responsibility of each?

  • External Data Sources: Refer to the different sources from which an application can obtain information, such as a database, web APIs, local files, device sensors, etc.

  • Data Layer: Manages interactions with data sources and allows other parts of the application to access data in an organized and secure manner. This layer is further divided into:

    • Data Sources: Connect directly with external data sources.

    • Repositories: Act as intermediaries between the data and application layers. They manage data coming from the data source, transform it, and save it in the cache or local database, among other functions.

  • Application Layer: Handles business logic and direct user interaction. It is divided into two parts:

    • Business Logic: Our blocs and cubits are located here. It is where we write about business logic and manage our widgets' state.

    • Presentation Layer: This is where all our widgets are located and is responsible for interactions and what the user can see: buttons, images, text, etc.

Note

In this article, we will not address the dotted part, which involves connecting to a local database to store news and accessing it when offline.

External Data Source: News API

In our application, the external data source is News API, and two APIs are of interest:

  1. /v2/everything: This API searches through millions of articles and will be used when the user enters text in the search bar.

  2. /v2/top-headlines: This API provides top news and headlines for a country and will be used on the homepage.

In the documentation, we can see that when calling either of these two APIs, a successful response returns a JSON like this:

{
  "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]"
    }
  ]
}

However, when the request is not successful, the response contains an error code (see here), and the JSON looks like this:

{
  "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."
}

Analyzing both responses, we will need two classes to model the response. The first class, let's call it Article, represents everything inside the articles list:

@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];
}

Note

We're using the following packages in the Article class:

  • Equatable: It simplifies comparing two objects of the same class without overriding == and hashCode. You can learn more here.

  • JsonSerializable: It generates code to convert JSON to objects of the Article class.

The second class represents the values of the response, either successful or unsuccessful. We're also using the Equatable and JsonSerializable packages:

@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];
}

Now that we've defined the classes to help represent JSON as Dart objects, we can proceed with the class responsible for making API requests.

Reminder

Execute the command to generate the necessary code for the JsonSerializable package: flutter pub run build_runner watch --delete-conflicting-outputs

Data Layer

As mentioned, this layer interacts with data sources, whether an external API or a local database. It also allows other parts of the application to access data in an organized and secure manner. It is divided into data sources and repositories.

Sure, here is the translation of the Markdown article with improved grammar:

Data Source: NewsDataSource

The NewsDataSource class will be responsible for making requests to the News APIs. We will use the http package, which allows us to make HTTP requests. Let's create the NewsDataSource class and initialize some variables:

class NewsDataSource {
  
  // Visit https://newsapi.org/ to obtain your 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();
}

We have added the base URL of News API and the paths for the two APIs we will use. We also have the API KEY, which we can obtain on the News API website. Finally, we have a Client object that will handle making requests to the APIs.

You might wonder why we are initializing the client as follows:

  final Client _httpClient;

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

Instead of doing it like this:

  final Client _httpClient = Client();

  NewsDataSource();

The answer is simple. Passing Client in the constructor allows us to pass a Mock to perform unit tests on the NewsDataSource class.

Now, let's create a helper function responsible for making requests to the APIs and returning an object of type ApiResponse with the response:

  Future<ApiResponse> _callGetApi({
    required String endpoint,
    required Map<String, String> params,
  }) async {
    
    // Create the URI for making a request to the API
    final uri = Uri.https(baseUrl, endpoint, params);
    final response = await _httpClient.get(uri);

    // Use `json.decode` to convert the response body to a Json object
    final result = ApiResponse.fromJson(json.decode(response.body));

    // If the response contains an error, throw an exception
    if (result.status == 'error') {
      if (result.code == 'apiKeyMissing') throw MissingApiKeyException();
      if (result.code == 'apiKeyInvalid') throw ApiKeyInvalidException();
      throw Exception();
    }

    // If there is no error, return the API result
    return result;
  }

// Define some custom exceptions that we can handle in the application layer
class MissingApiKeyException implements Exception {}
class ApiKeyInvalidException implements Exception {}

The _callGetApi function will be called whenever we make a request to either of the APIs. The error codes are in the News API documentation. For this example, we only create exceptions for apiKeyMissing and apiKeyInvalid.

Now, let's create two functions that will help us call the APIs we are interested in:

  // Make a request to /v2/top-headline. Receive the country
  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!;
  }

  // Make a request to /v2/everything. Receive the language and a search text
  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!;
  }

We have created two functions, fetchTopHeadlines and fetchEverything, each with its respective parameters. Each function constructs the params parameters according to each API's requirements.

We have completed all the functions, variables, etc., that comprise the NewsDataSource class. Putting it all together, the code is:

class MissingApiKeyException implements Exception {}
class ApiKeyInvalidException implements Exception {}

class NewsDataSource {
  // Visit https://newsapi.org/ to obtain your 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;
  }
}

Testing the NewsDataSource Class

Now let's perform tests on the NewsDataSource class. To do this, we will use the Mockito package, which allows us to create "mocks" and simulate successful and failed requests to the APIs.

We will create three .json files that will contain simulated responses from the API. The first file contains a failed response with the error apiKeyInvalid. With this file, we will test if the ApiKeyInvalidException exception is thrown correctly:

{
  "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."
}

The second file contains the error apiKeyMissing, and with it, we will test if the MissingApiKeyException exception is thrown correctly:

{
  "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."
}

The third file simulates the successful response from the API and contains two news:

{
  "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]"
    }
  ]
}

Let's create a file called data_source_test.dart inside the test folder and set up the basic structure:

// Mocks that allow us to simulate successful and failed requests
class MockClient extends Mock implements Client {}
class FakeUri extends Fake implements Uri {}

// Path where the .json files are located
const mockPath = 'test/data_source_test';

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

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

  // setUp is called before each test.
  setUp(() {
    mockClient = MockClient();

    // Create a NewsDataSource object and pass the mockClient
    dataSource = NewsDataSource(httpClient: mockClient);
  });

  // Group the tests for the fetchEverything function
  group('When calling fetchEverything', () {
     // Here we will write the tests for this group
  });

  // Group the tests for the fetchTopHeadlines function
  group('When calling fetchTopHeadlines', () {
     // Here we will write the tests for this group
  });
}

// Helper function to read .json files as Strings. 
Future<String> getMockBody(String filePath) => File(filePath).readAsString();

Important

In this article, we will only test the fetchEverything function. Your homework is to add tests to the fetchTopHeadlines function.

The first test we will do is to verify that calling the API is successful. Since the JSON file we created for successful tests contains two news, we can verify that calling the fetchEverything function returns two news:

    test('The response contains two news', () async {
      // Get the JSON string with the successful response
      final response = await getMockBody('$mockPath/success_response.json');

      // Tell the mockClient that when it receives a GET request,
      // return the JSON with the successful response and status 200. 
      when(() => mockClient.get(any())).thenAnswer((_) async {
        return Response(response, 200, headers: headers);
      });

      // Call the fetchEverything function
      final articles = await dataSource.fetchEverything(language: 'es');

      // The expected result is two news, each with a different author
      expect(articles.length, 2);
      expect(articles[0].author, 'Sophie Lewis');
      expect(articles[1].author, 'KOCO Staff');
    });

In the above test, we verify that calling the fetchEverything function returns the expected result. In the next test, we will verify that when calling the fetchEverything function with the arguments language:'es' and search:'Hello world', the Client receives the arguments successfully:

    test('The request contains the expected parameters', () async {
      // Get the JSON content of the successful response
      final response = await getMockBody('$mockPath/success_response.json');

      // Configure the mockClient so that, when receiving a GET request,
      // it returns the JSON of the successful response and a status 200. 
      when(() => mockClient.get(any())).thenAnswer((_) async {
        return Response(response, 200, headers: headers);
      });

      // Arguments that the Client should receive
      const language = 'es';
      const searchTerm = 'Hello world';

      // Call the fetchEverything function
      await dataSource.fetchEverything(
        language: language,
        search: searchTerm,
      );

      // Create the expected Uri with the provided arguments
      final uri = Uri.https(
        NewsDataSource.baseUrl,
        NewsDataSource.everything,
        {
          'apiKey': NewsDataSource.apiKey,
          'language': language,
          'q': searchTerm,
        },
      );

      // Verify that the Client was called with the expected Uri
      verify(() => mockClient.get(uri)).called(1);
    });

Lets create the test where the response is unsuccessful, and the function throws the MissingApiKeyException exception:

    test('Correctly throws Missing API Key Exception for non-successful response', () async {
      // Obtain the JSON content of the failed response
      final response = await getMockBody('$mockPath/api_key_missing.json');

      // Configure the mockClient so that, upon receiving a GET request,
      // it returns the JSON of the failed response and a status code of 200.
      when(() => mockClient.get(any())).thenAnswer((_) async {
        return Response(response, 200);
      });

      // When calling the fetchEverything function, we expect the
      // MissingApiKeyException exception to be thrown
      expect(
          () => dataSource.fetchEverything(language: 'es'),
          throwsA(
              predicate((exception) => exception is MissingApiKeyException)));
    });

And finally, the test where the response is unsuccessful, and the function throws the ApiKeyInvalidException exception:

    test('Correctly throws Invalid API Key Exception for non-successful response', () async {
      // Obtain the JSON content of the failed response
      final response = await getMockBody('$mockPath/api_key_invalid.json');

      // Configure the mockClient so that, upon receiving a GET request,
      // it returns the JSON of the failed response and a status code of 200.
      when(() => mockClient.get(any())).thenAnswer((_) async {
        return Response(response, 200);
      });

      // When calling the fetchEverything function, we expect the
      // ApiKeyInvalidException exception to be thrown
      expect(
          () => dataSource.fetchEverything(language: 'es'),
          throwsA(
              predicate((exception) => exception is ApiKeyInvalidException)));
    });

Fantastic, we have completed the NewsDataSource class and added tests to ensure it works as expected.

The following article will explore how to use dependency injection to initialize the NewsDataSource class so the repository can use it.

Remember that you can find the source code on GitHub. And don't forget to check out the other articles in this tutorial series:

Share this article