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:
-
Part 2: Dependency injection, repositories, and business logic.
-
Part 3: Presentation layer and support for multiple languages.(this article)
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.
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:
-
Search Bar: Users can enter text to search for news of interest.
-
News List: Comprising several news articles; clicking on any reveals the news details.
-
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
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.
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.
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:
-
/v2/everything: Supports the
language
parameter with values like es, en, zh, and many more. -
/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.
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: