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:

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

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

Places where the user can interact with the application

There are three places where the user can interact with the application:

  1. Search Bar: The user can enter text to search for news of interest.
  2. Language Selection: Let the user change the application's language.
  3. 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:

Share this article