Cubit in Action: Creating a Responsive News Application with Multi-Language Support. Part 2
This is the second part of this article series, in which 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.
Remember that you can find the source code on GitHub. And don't forget to check out the other articles in this tutorial series:
-
Part 2: Dependency injection, repositories, and business logic. (this article)
-
Part 3: Presentation layer and support for multiple languages.
Dependency Injection with GetIt
GetIt is a service locator package that helps us adhere to the Dependency Inversion Principle, which aims to reduce coupling between software components and promote flexibility and ease of maintenance.
Let's create a new file called dependency_injection.dart
, where we will initialize GetIt and create a helper function to inject NewsDataSource
:
final getIt = GetIt.instance;
Future<void> injectDependencies() async {
getIt.registerLazySingleton(() => NewsDataSource());
}
Then, in the main()
function, we call injectDependencies()
to register the dependencies:
void main() async {
// Inject dependencies when starting the application
injectDependencies();
runApp(const MyApp());
}
Why should we use dependency injection in our application? Later, when we write unit tests, we can create a "Mock" class, for example, MockNewsDataSource
, and inject it into the tests, allowing us to simulate different scenarios.
Data Layer
Repository: NewsRepository
In this small application, the repository is merely an intermediary between the data sources and the cubits.
Note
Remember that, in larger applications, the repository has several uses, such as managing data coming from the data source, transforming it, and saving it to the cache or local database, etc.
We create the NewsRepository
class with the fetchEverything
and fetchTopHeadlines
functions that call the data source to retrieve the data:
class NewsRepository {
// Get the data source we injected earlier
final NewsDataSource _dataSource = getIt();
// Call the data source to get all news
Future<List<Article>> fetchEverything({
required Locale locale,
String? search,
}) =>
_dataSource.fetchEverything(
language: locale.languageCode,
search: search,
);
// Call the data source to get the latest headlines
Future<List<Article>> fetchTopHeadlines({
required Locale locale,
}) =>
_dataSource.fetchTopHeadlines(
country: locale.countryCode!,
);
}
Finally, we have finished coding the data layer, where we created the NewsDataSource
class acting as the gateway to our external data sources and established a robust set of tests to ensure its correct operation in various situations. We also learned to use dependency injection with GetIt, providing flexibility and ease of testing.
The Application Layer
This layer is responsible for handling business logic and direct interaction with users. It interacts directly with the data layer to obtain and store information, making it crucial for writing unit tests since most of the business logic resides here.
Business Logic: NewsCubit
The NewsCubit
class is responsible for maintaining the user interface's state and calling the repository to retrieve news to be displayed to the user. Before coding the cubit, let's examine the possible states of the application:
Application States: Loading, Success, Error
-
Loading State: We show a loading animation on the user interface during this state. A request to the API is being made during this state. The
NewsLoadingState
class represents this state. -
Success State: When the API response is successful, we enter the success state and display a news list on the user interface. The
NewsSuccessState
class represents this state, containing the news list. -
Error State: If the API response fails, we enter the error state and display the error message on the user interface. The
NewsErrorState
class represents this state, containing the error message.
When creating a cubit, it's necessary to indicate the type of the state. In our case, there are three states: NewsLoadingState
, NewsSuccessState
, and NewsErrorState
. We achieve this by using inheritance:
// All states will extend from NewsState
sealed class NewsState extends Equatable {
@override
List<Object> get props => [];
}
// Loading state
class NewsLoadingState extends NewsState {}
// Success state
class NewsSuccessState extends NewsState {
// The list of news
final List<Article> news;
NewsSuccessState(this.news);
@override
List<Object> get props => [news];
}
// Error state
class NewsErrorState extends NewsState {
// The error message
final String message;
NewsErrorState(this.message);
@override
List<Object> get props => [message];
}
Now that we have defined the states, we can create the NewsCubit
class:
class NewsCubit extends Cubit<NewsState> {
// The cubit has a dependency in the repository
final NewsRepository _repository = getIt();
NewsCubit() : super(NewsLoadingState());
}
To complete the implementation of the NewsCubit
, we need to understand the types of interactions the user can have with the application. The following image illustrates these interactions:
Places where the user can interact with the application
There are three places where the user can interact with the application:
- Search Bar: The user can enter text to search for news of interest.
- Language Selection: Let the user change the application's language.
- Clear Button: Clears the content of the search bar and displays the latest news in the application.
Now we can create three functions in our cubit to handle the search, language change, and clear button events:
class NewsCubit extends Cubit<NewsState> {
final NewsRepository _repository = getIt();
// Helper variable containing the text we are searching for
String? _currentSearch;
// Helper variable containing the language and country of the news we are searching for
Locale _currentLocale;
// Initialize "locale" in the constructor
NewsCubit(Locale locale)
: _currentLocale = locale,
super(NewsLoadingState());
// When the user enters a search term, we call this function
Future<void> searchNews({
String? search,
}) async {
_currentSearch = search;
return _search();
}
// When the user presses the clear button, we call this function
Future<void> clearSearch() async {
_currentSearch = null;
return _search();
}
// When the user changes the language of the application, we call this function
Future<void> setLocale(Locale newLocale) async {
if (_currentLocale == newLocale) return;
_currentLocale = newLocale;
return _search();
}
// This function abstracts the search logic to avoid code repetition in the [searchNews], [clearSearch], and [setLocale] functions
Future<void> _search() async {
}
}
We have already created the three necessary functions for the presentation layer to send user-generated events to the NewsCubit
. We can also see that the three functions share the search logic, which we are going to implement in the private function _search()
:
Future<void> _search() async {
// The code is within a try-catch block to catch
// exceptions thrown from the data layer
try {
// Emit the loading state to have the presentation layer display the loading interface
emit(NewsLoadingState());
// Using a switch, if [_currentSearch] is null, call the API for the latest headlines
// but if it is not null, call the API to search for all news
final news = await switch (_currentSearch) {
null => _repository.fetchTopHeadlines(
locale: _currentLocale,
),
_ => _repository.fetchEverything(
locale: _currentLocale,
search: _currentSearch,
)
};
// Emit the success state with the list of news for the presentation layer to display the news
emit(NewsSuccessState(news));
} on Exception catch (e) {
// In case of any exception, catch it and emit an error state for the presentation
// layer to display the 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'));
}
}
}
We have completed all the functions, variables, etc., that comprise the NewsCubit
class. If we put everything together, the code will be:
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'));
}
}
}
}
Testing the NewsCubit Class
It's time to test that the NewsCubit
class works as expected. To test a cubit, we can use the bloc_test package, which makes testing cubits and blocs easier. Let's create a file called news_cubit_test.dart
inside the test folder and set up the basic structure:
// Mock will allow us to return mock data to the cubit from the repository
class MockNewsRepository extends Mock implements NewsRepository {}
// Mock Locale that we will use to pass to the cubit in the constructor and the setLocale function
const mockLocale = Locale('en', 'US');
// Calling the fetchTopHeadlines function will return this article
const mockTopArticle = Article(title: "TopArticle", url: "someUrl");
// Calling the fetchEverything function will return this article
const mockEverythingArticle = Article(title: "Everything", url: "someUrl");
void main() {
late MockNewsRepository mockRepo;
// setUp is called before each test.
setUp(() async {
mockRepo = MockNewsRepository();
// Injecting MockNewsRepository
getIt.registerSingleton<NewsRepository>(mockRepo);
});
// tearDown is called after each test.
tearDown(() async {
// Resetting getIt to its initial state
await getIt.reset();
});
}
Now, we need to configure MockNewsRepository
so that the fetchTopHeadlines
and fetchEverything
functions return an article when called:
setUp(() async {
mockRepo = MockNewsRepository();
getIt.registerSingleton<NewsRepository>(mockRepo);
// When the fetchEverything function is called with mockLocale and any
// search term, it returns a list containing the mockEverythingArticle
when(() => mockRepo.fetchEverything(
locale: mockLocale,
search: any(named: 'search'),
)).thenAnswer((_) async => [mockEverythingArticle]);
// When the fetchTopHeadlines function is called with mockLocale
// it returns a list containing the mockTopArticle
when(() => mockRepo.fetchTopHeadlines(locale: mockLocale))
.thenAnswer((_) async => [mockTopArticle]);
});
The first test we're going to perform is to verify that calling the searchNews
function emits the correct states and calls the fetchTopHeadlines
function correctly:
blocTest<NewsCubit, NewsState>(
'When the search term is null '
'fetchTopHeadlines will be called '
'and the state will contain the mockTopArticle',
// Create the cubit with mockLocale
build: () => NewsCubit(mockLocale),
// Call the searchNews function
act: (cubit) async => cubit.searchNews(),
// States should be emitted in the correct order
expect: () => [
NewsLoadingState(),
NewsSuccessState(const [mockTopArticle]),
],
// Verify that the fetchTopHeadlines function was called 1 time with the
// argument mockLocale
verify: (cubit) {
verify(() => mockRepo.fetchTopHeadlines(locale: mockLocale)).called(1);
}
);
In the second test, we'll pass a search text to the searchNews
function. Therefore, the fetchEverything
function should be called, and the states should be emitted correctly:
blocTest<NewsCubit, NewsState>(
'When the search term is not null '
'fetchEverything will be called '
'and the state will contain the mockEverythingArticle',
// Create the Cubit with mockLocale
build: () => NewsCubit(mockLocale),
// Call the searchNews function with a search text
act: (cubit) async => cubit.searchNews(search: 'Hello world'),
// States should be emitted in the correct order
expect: () => [
NewsLoadingState(),
NewsSuccessState(const [mockEverythingArticle]),
],
// Verify that the fetchEverything function was called 1 time with the
// arguments mockLocale and 'Hello world'
verify: (cubit) {
verify(
() => mockRepo.fetchEverything(
locale: mockLocale,
search: 'Hello world',
),
).called(1);
}
);
For the third test, we'll call the searchNews
function with a search text and then call the clearSearch
function. We need to verify that the fetchEverything
and fetchTopHeadlines
functions are called correctly and that the NewsCubit
states are emitted correctly:
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',
// Create the Cubit with mockLocale
build: () => NewsCubit(mockLocale),
// Call the searchNews function with a search text
// and then call the clearSearch function
act: (cubit) async {
await cubit.searchNews(search: 'Hello world');
await cubit.clearSearch();
},
// States should be emitted in the correct order
expect: () => [
NewsLoadingState(),
NewsSuccessState(const [mockEverythingArticle]),
NewsLoadingState(),
NewsSuccessState(const [mockTopArticle]),
],
// Verify that the fetchEverything and fetchTopHeadlines functions
// were called 1 time with the correct arguments
verify: (cubit) {
verify(
() => mockRepo.fetchEverything(
locale: mockLocale,
search: 'Hello world',
),
).called(1);
verify(() => mockRepo.fetchTopHeadlines(locale: mockLocale)).called(1);
}
);
Finally, let's add one more test. In this test, we want to verify that the error state is emitted correctly if the repository throws an exception:
blocTest<NewsCubit, NewsState>(
'When the Api key is not valid exception is handled correctly',
build: () {
// Configure mockRepo to throw an ApiKeyInvalidException
// when the fetchTopHeadlines function is called
when(() => mockRepo.fetchTopHeadlines(locale: mockLocale))
.thenAnswer((_) async => throw ApiKeyInvalidException());
// Create the cubit with mockLocale
return NewsCubit(mockLocale);
},
// Call the searchNews function
act: (cubit) async => cubit.searchNews(),
// States should be emitted in the correct order and
// the last state is the error state
expect: () => [
NewsLoadingState(),
NewsErrorState('The API key is not valid'),
],
);
Great! We have completed the NewsCubit
class, which will be responsible for maintaining the application's state. We have also added tests to ensure it works as expected.
In the next article, we will work with the presentation layer to learn how to create a responsive application supporting multiple languages.
Remember that you can find the source code on GitHub. And don't forget to check out the other articles in this tutorial series: