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

This is the third 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:

Presentation Layer: User Interface

As mentioned, the presentation layer handles interactions and what the user can see: buttons, images, text, etc.

We need to create an application with support for three different screen sizes:

Responsive app with support for three sizes.

Responsive app with support for three sizes.

User Interface: Computer

Let's begin with the largest screen size designed for a desktop computer. We'll break down the user interface into different parts:

We can identify three main components:

  1. Search Bar: Users can enter text to search for news of interest.

  2. News List: Comprising several news articles; clicking on any reveals the news details.

  3. News Details: Users can view news details, and clicking "Read More" opens the browser to the news page.

While each of these parts can be further broken down, let's focus on these three. For the search bar, we'll create a widget called SearchWidget. For news details, we'll create a widget named DetailsWidget, and for each article in the news list, the widget will be called ListItem.

Let's start by creating the SearchWidget:

// Using Flutter Hooks instead of a StatefulWidget.
class SearchWidget extends HookWidget {
  const SearchWidget({
    Key? key,
    required this.onChanged,
    required this.onClearPress,
  });

  // Callback is executed when the clear button is pressed in the search bar.
  final VoidCallback onClearPress;

  // Callback executed each time the search term changes.
  final ValueChanged<String> onChanged;

  @override
  Widget build(BuildContext context) {
    // Create a TextEditingController with Flutter Hooks.
    final controller = useTextEditingController();

    // State that helps us show or hide the clear button
    // based on whether there is text in the search bar or not.
    final searchTermEmpty = useState(controller.text.isEmpty);

    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(
        controller: controller,
        onChanged: (text) {
          // Delay the call to "onChanged" to avoid multiple API calls.
          EasyDebounce.debounce(
            'search-news',
            const Duration(milliseconds: 300),
            () => onChanged.call(text),
          );
          searchTermEmpty.value = controller.text.isEmpty;
        },
        decoration: InputDecoration(
          prefixIcon: const Icon(Icons.search),
          labelText: 'Search News',
          // Hide or show the clear button based on whether there is text.
          suffixIcon: searchTermEmpty.value
              ? null
              : IconButton(
                  onPressed: () {
                    controller.clear();
                    onClearPress.call();
                  },
                  icon: const Icon(Icons.clear),
                ),
          border: const OutlineInputBorder(),
        ),
      ),
    );
  }
}

Learn More

In the SearchWidget widget, we have utilized the following packages:

  • Flutter Hooks: It helps eliminate repetitive code and makes it more readable. Learn more about it in this videotutorial.

  • EasyDebounce: This package prevents excessive API calls when users type in the search bar.

Now, let's proceed to create the DetailsWidget:

class DetailsWidget extends StatelessWidget {
  const DetailsWidget({
    Key? key,
    required this.article,
  });

  final Article article;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: double.infinity,
      child: SingleChildScrollView(
        child: Column(
          children: [
            // If the news lacks an image, display a red container;
            // otherwise, use `CachedNetworkImage` to show it.
            article.urlToImage == null
                ? Container(color: Colors.red, height: 250)
                : CachedNetworkImage(
                    width: 450,
                    imageUrl: article.urlToImage!,
                    placeholder: (context, url) => const Center(
                      child: CircularProgressIndicator(),
                    ),
                    errorWidget: (context, url, error) =>
                        const Icon(Icons.error),
                  ),
            Text(
              article.title,
              style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
            ),
            const SizedBox(height: 8),
            Text('${article.content}'),
            const SizedBox(height: 8),

            // Clicking the button opens the news in the browser.
            ElevatedButton(
              onPressed: () => launchUrlString(article.url),
              child: const Text('Read More'),
            ),
            const SizedBox(height: 16),
          ],
        ),
      ),
    );
  }
}

Now, let's create the ListItem widget:

// Constant to define the width and height of the image.
const _imageSize = 120.0;

class ListItem extends StatelessWidget {
  const ListItem({
    Key? key,
    required this.article,
    required this.onTap,
  });

  final Article article;
  final GestureTapCallback onTap;

  @override
  Widget build(BuildContext context) {
    // GestureDetector helps detect if this widget has been pressed.
    return GestureDetector(
      onTap: onTap,
      child: Card(
        margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // `_Image` is a private widget that helps show the image or
            // display an empty container if the news lacks an image.
            _Image(url: article.urlToImage),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    // Display the news title.
                    Text(
                      article.title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 16,
                      ),
                    ),
                    // If the news has a description, display it.
                    if (article.description != null) ...[
                      const SizedBox(height: 16),
                      Text(
                        article.description!,
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      )
                    ]
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// `_Image` is a private widget that helps show the news image.
class _Image extends StatelessWidget {
  const _Image({
    required this.url,
  });

  final String? url;

  @override
 

 Widget build(BuildContext context) {
    // If the URL is null, display a red container.
    return url == null
        ? Container(
            width: _imageSize,
            height: _imageSize,
            color: Colors.red,
          )
        // If the URL is not null, use `CachedNetworkImage` to show the image.
        : CachedNetworkImage(
            width: _imageSize,
            height: _imageSize,
            imageUrl: url!,
            fit: BoxFit.cover,
            // While the image is loading, show a CircularProgressIndicator.
            placeholder: (context, url) => const SizedBox(
              width: _imageSize,
              height: _imageSize,
              child: Center(
                child: CircularProgressIndicator(),
              ),
            ),
            // In case of an error, display the error icon.
            errorWidget: (context, url, error) => const Icon(Icons.error),
          );
  }
}

We have now built the three main components: the search bar SearchWidget, the news details DetailsWidget, and the widget representing each news item in the news list ListItem. The next step is to integrate NewsCubit with the user interface.

Integrating NewsCubit with the User Interface

Let's create the home screen, which will be displayed when users open the application. We'll name it NewsScreen.

class NewsScreen extends StatelessWidget {
  const NewsScreen({Key? key});

  @override
  Widget build(BuildContext context) {
    // Get the current locale of the application.
    final locale = Localizations.localeOf(context);

    // Use BlocProvider to create a NewsCubit and add it to the widget tree.
    return BlocProvider<NewsCubit>(
      // Create a NewsCubit with the current locale and call the searchNews function
      // to initiate the search as soon as the NewsScreen is visible.
      create: (context) => NewsCubit(locale)..searchNews(),
      child: Scaffold(
        appBar: AppBar(
          title: Text('News App'),
        ),
        // Use BlocBuilder to act according to the states of NewsCubit.
        body: BlocBuilder<NewsCubit, NewsState>(
          builder: (context, state) {
            // Depending on the state, return different widgets.
            return ?????;
          },
        ),
      ),
    );
  }
}

Now, let's display the NewsScreen when the application is launched:

void main() async {
  // Inject dependencies when starting the application.
  injectDependencies();

  runApp(
    // Create a MaterialApp that displays the NewsScreen.
    const MaterialApp(
      title: 'Flutter Demo',
      home: NewsScreen(),
    ),
  );
}

Depending on the current state of the cubit, we should return a different widget in the builder function of BlocBuilder:

    BlocBuilder<NewsCubit, NewsState>(
      builder: (context, state) {
        // Use a column to align the widgets.
        return Column(
          children: [
            // The first widget is the search bar.
            SearchWidget(
              // When the text changes, if it is longer than three characters,
              // call searchNews to perform a new search.
              onChanged: (text) {
                context
                    .read<NewsCubit>()
                    .searchNews(search: text.length > 3 ? text : null);
              },
              // When the clear button is pressed, call clearSearch to perform a new search without a search term.
              onClearPress: context.read<NewsCubit>().clearSearch,
            ),
            // Then we have an "Expanded" because the content after the search bar will take up the remaining space.
            Expanded(
              // The "Builder" is unnecessary, but it helps to use "if else" more organized.
              child: Builder(
                builder: (context) {
                  if (state is NewsSuccessState) {
                    // If the state is successful, display the _BigSize widget.
                    return _BigSize(news: state.news);
                  } else if (state is NewsErrorState) {
                    // If the state is an error, display text with the error.
                    return Text(state.message);
                  } else {
                    // If the state is loading, display a loading indicator.
                    return const Center(
                      child: CircularProgressIndicator(),
                    );
                  }
                },
              ),
            ),
          ],
        );
      },
    )

We've implemented the content of the BlocBuilder, but there's a widget we haven't created yet. _BigSize is the widget containing the large screens' user interface structure. Let's see how to implement it:

class _BigSize extends HookWidget {
  // Initialize the list of news in the constructor.
  const _BigSize({required this.news});

  final List<Article> news;

  @override
  Widget build(BuildContext context) {
    // Use hooks to maintain the state of the selected news.
    final currentNew = useState(news.firstOrNull);

    // Use Row to separate the screen into two. The news list is on the left, and the details are on the right.
    return Row(
      children: [
        // The news list has a fixed width of 450.
        SizedBox(
          width: 450,
          child: ListView.builder(
            itemCount: news.length,
            // When pressing an item in the list, update the local state with the selected news.
            itemBuilder: (_, int index) => ListItem(
              article: news[index],
              onTap: () => currentNew.value = news[index],
            ),
          ),
        ),
        // Display the details of the selected news.
        if (currentNew.value != null)
          Expanded(
            child: DetailsWidget(
              article: currentNew.value!,
            ),
          ),
      ],
    );
  }
}

Now, we can run the application and see the result.

Application without phones and tablets support

Application without phones and tablets support

The application looks good; we can select different news, but the problem is that the application does not adapt to screen changes and shows overflow errors. Let's continue by adding support for other screen sizes.

User Interface: Tablet or iPad

It's time to add support for tablets and iPads. In this case, the news list and details will be on separate screens, as shown in the following image:

1. When pressing a news item in the list, we navigate to the news details.

1. When pressing a news item in the list, we navigate to the news details.

Let's create a new screen called NewsDetailScreen, which will be used to view the details of the news:

// Define a type that we will use to pass the news
// as an argument during navigation.
typedef NewsDetailScreenArgs = ({Article article});

class NewsDetailScreen extends StatelessWidget {
  const NewsDetailScreen({Key? key});

  @override
  Widget build(BuildContext context) {
    // Get the news.
    final article = context.args<NewsDetailScreenArgs>().article;

    // Create a Scaffold with an AppBar and use the DetailsWidget
    // widget to display the news details.
    return Scaffold(
      appBar: AppBar(
        title: const Text('News Details'),
      ),
      body: DetailsWidget(article: article),
    );
  }
}

The above code shows that the NewsDetailScreen class is straightforward since we are reusing the DetailsWidget.

We will use routes for navigation, so let's create a new class called Routes that will manage the routes.

class Routes {
  // Root route
  static const newsScreen = '/';

  // News details route
  static const detailsScreen = '/details';

  static Route routes(RouteSettings settings) {

    // Helper function to create MaterialPageRoute and avoid
    // having too much repetitive code.
    MaterialPageRoute buildRoute(Widget widget) {
      return MaterialPageRoute(builder: (_) => widget, settings: settings);
    }

    // Use a switch to navigate to the desired route; if the route
    // does not exist; throw an exception.
    return switch (settings.name) {
      newsScreen => buildRoute(const NewsScreen()),
      detailsScreen => buildRoute(const NewsDetailScreen()),
      _ => throw Exception('Route does not exist'),
    };
  }
}

We must also modify MaterialApp and use onGenerateRoute instead of home.

    return MaterialApp(
      title: 'Flutter Demo',
      onGenerateRoute: Routes.routes,
    );

Great, we now have navigation ready, but we must also add support for tablets and iPads. Let's create a new private widget that will display the list on medium-sized screens:

class _MediumSize extends StatelessWidget {
  // Initialize the list of news in the constructor.
  const _MediumSize({required this.news});

  final List<Article> news;

  @override
  Widget build(BuildContext context) {
    // Create the news list.
    return ListView.builder(
      itemCount: news.length,
      itemBuilder: (_, int index) => ListItem(
        article: news[index],
        onTap: () {
          // When pressing an item in the list, navigate to the details screen.
          final args = (article: news[index]);
          Navigator.pushNamed(context, Routes.detailsScreen, arguments: args);
        },
      ),
    );
  }
}

Finally, to decide whether to show the _BigSize or _MediumSize widget, we will use a LayoutBuilder as follows:

    return LayoutBuilder(
      builder: (context, constraints) {
        // If the available maximum width is less than 900 units, 
        // show _MediumSize; otherwise, show _BigSize.
        return switch (constraints.maxWidth) {
          < 900 => _MediumSize(news: state.news),
          _ => _BigSize(news: state.news),
        };
      },
    );

If we run the application and resize the screen, we can see that the user interface adapts correctly.

User Interface: Mobile

It's time to add support for mobile phones. But this part is a task for the reader, so let's see how the user interface looks:

1. When pressing a news item in the list, we navigate to the news details.

1. When pressing a news item in the list, we navigate to the news details.

The user interface is very similar to the tablet interface, so it won't be complicated to create. But here are some tips:

  • Create a new widget to represent each news item in the list.

  • Create a new widget to display the news list. For consistency in the code, I would call it _SmallSize.

  • Modify the logic of the LayoutBuilder to display the _SmallSize widget.

  • NO need to create any new screens or routes.

If you encounter any issues, remember that you can download the source code from GitHub.

Multi-language Support

Now, let's add support for multiple languages to the application. We will use the EasyLocalization package, simplifying adding multiple languages. Learn more about this package in this videotutorial.

First, we need to decide which languages we want to support. We can refer to the documentation of the two APIs we are going to support:

  1. /v2/everything: Supports the language parameter with values like es, en, zh, and many more.

  2. /v2/top-headlines: Supports the country parameter with values like mx, ve, us, tw, and many more.

This application will only add support for a few languages and countries. Let's start by creating a list of supported locales:

const spanishMx = Locale('es', 'MX'); // Spanish from Mexico
const spanishVe = Locale('es', 'VE'); // Spanish from Venezuela
const englishUs = Locale('en', 'US'); // English from the USA
const chineseTw = Locale('zh', 'TW'); // Chinese from Taiwan

// Map with the names of the supported languages
final supportedLocales = <Locale, String>{
  englishUs: 'English - US',
  spanishMx: 'Español - MX',
  spanishVe: 'Español - VE',
  chineseTw: '中文 - TW',
};

We also need three JSON files with translations for the supported languages.

For English, we have en.json:

{
  "top_headlines": "Top Headlines",
  "search_news": "Search News",
  "view_more": "View more"
}

For Spanish, we have es.json:

{
  "top_headlines": "Titulares Principales",
  "search_news": "Buscar Noticias",
  "view_more": "Ver más"
}

And for Chinese, we have zh.json:

{
  "top_headlines": "最新的新聞",
  "search_news": "找新聞",
  "view_more": "看更多"
}

EasyLocalization supports code generation that creates each key in a map, making it easier to access in the code. For example:

// Without code generation, we might make mistakes like the following:
Text('top_headLines'.tr());

// Did you find the error? Instead of writing 'top_headlines', we wrote 'top_headLines'.

// But with code generation, we can't make any mistakes:
Text(LocaleKeys.top_headlines.tr())

Let's run the following command to generate all the necessary code:

flutter pub run easy_localization:generate -f keys -S resources/translations -O lib/src/localization -o locale_keys.g.dart
flutter pub run easy_localization:generate -S resources/translations -O lib/src/localization

Note

Don't worry if you don't understand the above command. In the source code, you can find the generate_code.sh file and execute it from the terminal or console to generate all the necessary code.

After running the above command, the codegen_loader.g.dart and locale_keys.g.dart files will be generated inside the lib/src/localization folder.

After generating the necessary code, let's initialize the EasyLocalization package. We'll modify the main() function as follows:

void main() async {

  // Ensure the initialization of WidgetsFlutterBinding and EasyLocalization
  WidgetsFlutterBinding.ensureInitialized();
  await EasyLocalization.ensureInitialized();

  injectDependencies();
  runApp(const MyApp());
}

We also need to configure EasyLocalization and enclose our MaterialApp with the EasyLocalization widget:

class MyApp extends StatelessWidget {
  const MyApp({Key? key});

  @override
  Widget build(BuildContext context) {
    return EasyLocalization(
      // The fallback language is English from the USA
      fallbackLocale: englishUs,
      useFallbackTranslations: true,

      // Pass the list of supported languages
      supportedLocales: supportedLocales.keys.toList(),

      // path is not necessary because we are using generated code
      path: 'xxx',

      // Pass the generated class
      assetLoader: const CodegenLoader(),

      // Enclose MaterialApp with the EasyLocalization widget
      child: const _MaterialApp(),
    );
  }
}

class _MaterialApp extends StatelessWidget {
  const _MaterialApp();

  @override
  Widget build(BuildContext context) {
    // Configure MaterialApp to support different languages
    return MaterialApp(
      title: 'Flutter Demo',
      locale: context.locale,
      onGenerateRoute: Routes.routes,
      debugShowCheckedModeBanner: false,
      supportedLocales: context.supportedLocales,
      localizationsDelegates: context.localizationDelegates,
    );
  }
}

Let's return to the NewsScreen class and make two modifications. The first modification is to use the EasyLocalization extension to get the current locale. So, replace the following code:

// Replace this line of code
final locale = Localizations.localeOf(context);

// with
final locale = context.locale;

The difference in the above code is that using the EasyLocalization extension is much shorter.

We also need to add a PopupMenuButton that allows us to select between the languages supported by our application:

PopupMenuButton to select the language.

PopupMenuButton to select the language.

Let's create a widget called _LanguagePopUpMenu that will allow us to display a menu to select from the supported languages:

class _LanguagePopUpMenu extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Create a PopupMenuButton with the translate icon
    return PopupMenuButton<Locale>(
      icon: const Icon(Icons.translate),
      itemBuilder: (context) {
        // Iterate through each of the supported languages to create
        // a list of PopupMenuItem
        return supportedLocales.entries.map((it) {
          return PopupMenuItem(
            value: it.key,
            child: Text(it.value),
          );
        }).toList();
      },
      // Each time we select a language from the menu, update
      // the language of the application and the cubit.
      onSelected: (selected

Locale) {
        context.setLocale(selectedLocale);
        context.read<NewsCubit>().setLocale(selectedLocale);
      },
    );
  }
}

Then, add it to the AppBar in the actions: property:

 // Add the _LanguagePopUpMenu widget to the AppBar
 appBar: AppBar(
   title: Text(LocaleKeys.top_headlines.tr()),
   actions: [
      _LanguagePopUpMenu(),
   ],
 ),

Now, we can run the application, and we will have the option to select the language and search in any of the supported languages.

This concludes this series of articles, so remember that you can find the source code on GitHub. And don't forget to visit the other articles in this tutorial series:

Share this article