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.
You can find the source code on GitHub. Also, don't forget to check out the other articles in this tutorial series:
-
Part 1: Introduction, architecture, and data sources. (this article)
-
Part 2: Dependency injection, repositories, and business logic.
-
Part 3: Presentation layer and support for multiple languages.
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):
- Obtain an API key from https://newsapi.org/.
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.
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:
-
/v2/everything: This API searches through millions of articles and will be used when the user enters text in the search bar.
-
/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
==
andhashCode
. 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: