8. Language Management

8. Language Management

Hello, Welcome back. Glad that you’re here.

We're building a Movie App with the best coding practices and tools. In this tutorial, we'll introduce language options in the app.

Languages Entity

We're supporting two languages English and Spanish. In the future, the number of languages can increase so to properly provide a setup that can scale over time, let's create LanguageEntity.

In domain/entities folder, create a new file language_entity.dart:

class LanguageEntity {
 //1
 final String code;
 //2
 final String value;
 //3
 const LanguageEntity({@required this.code, @required this.value});
}
  1. Declare code as the final field. Code stores the language code - en and es.
  2. Now declare value as another final field. The value will store the English and Spanish. Navigation Drawer will use these values in the dropdown.
  3. Create the constructor with both fields as required.

Now declare a list of languages that you want to support.

In common/constants folder, create a new file languages.dart:

class Languages {
 //1
 const Languages._();

 //2
 static const languages = [
  LanguageEntity(code: 'en', value: 'English'),
  LanguageEntity(code: 'es', value: 'Spanish'),
 ];
}
  1. Declare a private constructor.
  2. Create an array of languages with 2 Language Entities.

In the previous tutorial of the navigation drawer, we have used direct language strings. Let's replace them with the entries in the language constants.

NavigationExpandedListTile(
 title: TranslationConstants.language,
 //1
 children: Languages.languages.map((e) => e.value).toList(),
 onPressed: (index) {},
)
  1. Use the map operator that would return a list of values in the Language Entity. Since the map operator returns Iterator, you need to use toList to convert it to a list.

Supported Locales & Default Locale

Now you have declared the locales, you need to tell MaterialApp about those locales and also set a locale that the app will use at the launch. Open movie_app.dart and add below snippet in MaterialApp:

//1
supportedLocales: Languages.languages.map((e) => Locale(e.code)).toList(),
//2
locale: Locale(Languages.languages[0].code),
  1. supportedLocales take in an array as well, but an array of codes. So this time instead of value, you will use the code. i.e. en and es.
  2. Assign the en locale, which will be the language used at the start of the app.

Default Localization Delegates

Open pubspec.yaml and add the _flutterlocalization dependency:

flutter_localizations:
  sdk: flutter

Widgets in the Flutter framework already have delegates that help in RTL support. So, to provide that to all the widgets in the app, you can add localizationsDelegates:

localizationsDelegates: [
 //1
 GlobalMaterialLocalizations.delegate,
 GlobalWidgetsLocalizations.delegate,
]
  1. As the name suggests, the first one is for MaterialWidgets and another one for any type of widget.

Language JSON

We'll declare all the strings used in the app in JSON files. As we're supporting 2 languages, we will add 2 JSON files in the assets folder.

Create a languages folder under assets folder Add 2 JSONs in languages folder, en.json and es.json

Update pubspec.yaml with new assets entry:

- assets/language/

Add the strings that are there in the app till now. Like, tab texts and text in the navigation drawer.

In en.json add the English text and in es.json add the Spanish text.

I have taken Spanish text from Google Translate.

{
  "favoriteMovies": "Favorite Movies",
  "language": "Language",
  "about": "About",
  "feedback": "Feedback",
  "popular": "POPULAR",
  "now": "NOW",
  "soon": "SOON",
}
{
  "favoriteMovies": "Peliculas favoritas",
  "language": "Idioma",
  "about": "Acerca de",
  "feedback": "Realimentación",
  "popular": "POPULAR",
  "now": "AHORA",
  "soon": "PRONTO",
}

It is important to have the same key in both the JSON files.

Custom Localizations Delegate

As we added MaterialLocalization Delegate before in MaterialApp, we can also create custom delegates to load our custom translated strings from en.json and es.json.

In presentation folder, create a new file app_localizations.dart:

//1
class AppLocalizations {
 //2
 final Locale locale;

 AppLocalizations(this.locale);

 //6
 static const LocalizationsDelegate<AppLocalizations> delegate =
   _AppLocalizationDelegate();
}
//3
class _AppLocalizationDelegate extends LocalizationsDelegate<AppLocalizations> {
 //4
 const _AppLocalizationDelegate();

 @override
 bool isSupported(Locale locale) {
  throw UnimplementedError();
 }

 @override
 Future<AppLocalizations> load(Locale locale) {
  throw UnimplementedError();
 }

 //5
 @override
 bool shouldReload(covariant LocalizationsDelegate<AppLocalizations> old) =>
   false;
}
  1. Create a class AppLocalizations.
  2. We will load translations bases on Locale so add a Locale final field.
  3. Declare a delegate that extends LocalizationsDelegate of type AppLocalizations.
  4. Give it a const constructor.
  5. Override the 3 methods and return false from shouldReload(). We'll work on other methods in a moment.
  6. In AppLocalizations, add a delegate field that contains an instance of _AppLocalizationDelegate.

Now, in movie_app.dart, in the delegates add AppLocalizations.delegate as well.

localizationsDelegates: [
 AppLocalizations.delegate,
 GlobalMaterialLocalizations.delegate,
 GlobalWidgetsLocalizations.delegate,
],

You'll get UnimplementedException as we are not completely done with loading specific translations for a specific Locale.

In the delegate class add body to the other 2 overridden methods:

//1
@override
bool isSupported(Locale locale) {
 return Languages.languages
   .map((e) => e.code)
   .toList()
   .contains(locale.languageCode);
}

@override
Future<AppLocalizations> load(Locale locale) async {
 //2
 AppLocalizations localizations = AppLocalizations(locale);
 //3
 await localizations.load();
 //4
 return localizations;
}
  1. This is basically for safety checks that the locale that we are loading is supported by the application. So, you'll check whether the locale selected is in the list of your locales.
  2. Here, you'll create an instance of AppLocalization with the selected locale.
  3. Then, you'll load the JSON that you have created based on the language code. The load() will be created in just a moment.
  4. After successfully loading the JSON map, you'll return the localization.

Now, create load method in AppLocalizations:

//1
Map<String, String> _localizedStrings;

//2
Future<bool> load() async {
 //3
 final String jsonString = await rootBundle
   .loadString('assets/language/${locale.languageCode}.json');
 //4
 final Map<String, dynamic> jsonMap = json.decode(jsonString);

 //5
 _localizedStrings = jsonMap.map((key, value) {
  return MapEntry(key, value.toString());
 });

 return true;
}
  1. Declare a map of String as key and String as value. This will hold the parsed JSON for en.json and es.json.
  2. Create the load method to return true or false.
  3. First thing in the method you'll load the JSON file from assets. You'll load a dynamic JSON based on the locale.languageCode.
  4. Now decode the JSON to the map.
  5. Since, convert library cannot guarantee the value type, we need to use the toString method on the value. For that, use the map operator and insert each value in the localizedStrings map.

Now, you have got the map that has keys and values from en.json or es.json. The next thing would be to get a string by key from this map. So, create a function to return the value.

String translate(String key) {
 //1
 return _localizedStrings[key];
}
  1. Very straight-forward as this just return the value by key from localizedStrings.

One last thing before we start using translated strings is a way to get an instance of the AppLocalizations.

Create a static of in AppLocalizations:

//1
static AppLocalizations of(BuildContext context) {
 return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
  1. Using the type of object, you can get the instance of AppLocalizations from Localizations.

Now, use translations in one of the places. Open navigation_drawer.dart and update the Favorite Movies title:

AppLocalizations.of(context).translate('favoriteMovies')
  1. First, we get the instance of AppLocalizations, then we pass the key in translate(). This will return Favorite Movies in English locale.

Run the app now. Everything is the same as previously. Change the locale in MaterialApp to Spanish and run the app again.

You can see the translated text for Favorite Movies as Peliculas favoritas.

Translation Constants

If you see hardcoded string 'favoriteMovies' used as the key. When you've many instances of this key, you'll have to write it again. So, it is better to put these in a constants file.

In common/constants folder, create a new file translation_constants.dart:

//1
class TranslationConstants {
 TranslationConstants._();

 static const String favoriteMovies = 'favoriteMovies';
 static const String language = 'language';
 static const String about = 'about';
 static const String feedback = 'feedback';
 static const String popular = 'popular';
 static const String now = 'now';
 static const String soon = 'soon';
}
  1. Here we will store keys in constants so that we can now use them when translating a text.

Use these constants and AppLocalizations everywhere in the project where you are using text. We'll not use them for English and Spanish text as they will never change when locale changes. After you're done using translated strings, run the app. You'll see when you change the default locale in MaterialApp, the text changes based on Locale.

String Extension

You've seen everywhere we use AppLocalizations.of(context).translate('key'). To reduce this, let's create a string extension.

In common/extensions folder, create a new file string_extensions.dart:

extension StringExtension on String {
 String t(BuildContext context) {
  return AppLocalizations.of(context).translate(this);
 }
}
  1. Just create a method t(), that will replace the key in the translate method.

Now, go on and update everywhere in the app where you used AppLocalizations.of(context).translate('some_key').

Run the app now, absolutely no difference. We have now created a method that can be used anywhere in the application.

Language Bloc

You've seen I every time change the locale in MaterialApp to see different translations. Let's add a Bloc to handle this for us.

In presentation/blocs folder, create a new bloc - language_bloc:

Create event - ToggleLanguageEvent:

//1
class ToggleLanguageEvent extends LanguageBlocEvent {
 //2
 final LanguageEntity language;

 const ToggleLanguageEvent(this.language);
 //3
 @override
 List<Object> get props => [language.code];
}
  1. An event that extends the LanguageBlocEvent.
  2. This event will take a language entity when we select a specific language from the navigation drawer.
  3. Give code in the props.

Delete the InitialState as it is not required here. We'll use the new state instead of the initial state.

Create a state - LanguageLoaded:

//1
class LanguageLoaded extends LanguageBlocState {
 //2
 final Locale locale;

 const LanguageLoaded(this.locale);

 //3  
 @override
 List<Object> get props => [locale.languageCode];
}
  1. A state that extends the LanguageBlocState.
  2. Based on language code, we will generate a Locale, so give a field of Locale type.
  3. In props add the code again.

Handle the ToggleLanguageEvent in the Bloc now.

if (event is ToggleLanguageEvent) {
 yield LanguageLoaded(Locale(event.language.code));
}
  1. For now, use the language code coming from an event in creating the state and yield the state.

Also, you want to load a default language on Bloc initialization.

//1
super(
 //2
 LanguageLoaded(
  //3
  Locale(Languages.languages[0].code),
 ),
)
  1. Update the super() of the Bloc.
  2. Pass in the LanguageLoaded state.
  3. Take the first language, that is English/en in our case, and create Locale from it.

By mistake, I have created language_bloc, instead of language. So kindly rename everything in event, state, and bloc files.

Register this bloc in get_it.dart:

getItInstance.registerSingleton<LanguageBloc>(LanguageBloc());

Now in movie_app.dart make the MovieApp a stateful widget.

//1
LanguageBloc _languageBloc;

@override
void initState() {
 super.initState();
 //2
 _languageBloc = getItInstance<LanguageBloc>();
}

@override
void dispose() {
 //3
 _languageBloc.close();
 super.dispose();
}
  1. Declare the Language Bloc variable.
  2. In initState, get the instance of LanguageBloc from getItInstance.
  3. Don't forget to close the Bloc in the dispose method.

Now wrap MaterialApp with BlocProvider and BlocBuilder:

//1
BlocProvider<LanguageBloc>.value(
 value: _languageBloc,
 //2
 child: BlocBuilder<LanguageBloc, LanguageState>(
  builder: (context, state) {
   //3
   if (state is LanguageLoaded) {
    return MaterialApp(
     //.....
     //.....Rest code remains same
     //4
     locale: state.locale,
     //.....Rest code remains same
    );
   }
   //5
   return const SizedBox.shrink();
  },
 ),
)
  1. Use BlocProvider to provide the app with LanguageBloc.
  2. Use BlocBuilder to read the LanguageBloc down the tree.
  3. Now if the state is LanguageLoaded, you have the locale with you. Build MaterialApp only if you have LanguageLoaded state.
  4. Instead of taking locale from one of the languages, take it from the state now. When the state changes, the locale will also change and will update all the strings in the application.
  5. When the state is not LanguageLoaded, you can return a SizedBox.

Now on tap of English or Spanish in the navigation drawer, we'll dispatch the LanguageToggleEvent. Open navigation_drawer.dart:

NavigationExpandedListItem(
 title: TranslationConstants.language.t(context),
 children: Languages.languages.map((e) => e.value).toList(),
 //1
 onPressed: (index) {
  BlocProvider.of<LanguageBloc>(context).add(
   ToggleLanguageEvent(
    Languages.languages[index],
   ),
  );
 },
),
  1. First, we can get the instance of LanguageBloc from BlocProvider. Dispatch the ToggleLanguageEvent. Based on what index we get, we will take the language from the languages list.

In navigation_expanded_list_tile.dart, call the onPressed() with index:

NavigationSubListItem(
 title: children[i],
 //1
 onPressed: () => onPressed(i),
),
  1. This will call the onPressed() passed from NavigationExpandedListTile.

Also, in the previous tutorial, I added GestureDetector in the NavigationExpandedListTile. You've to remove that. Because we are handling tap of sub-list tiles and not the main tile. Tap on the main tile is already handled using ExpansionTile.

Now, run the application and play with switching languages.

One last thing, I forgot to mention in the last tutorial. we have to give a color to unselectedWidgetColor in ThemeData because ExpansionTile uses this color when in a collapsed state. Without this, we are unable to see the collapsed arrow on the right side of the Language tile.

unselectedWidgetColor: AppColor.royalBlue,

Now, run the application and you'll see the down arrow animating to up arrow when the expansion tile expands and vice-versa.

By this, we've added 2 languages that you can switch in the app. This was all about creating Language Management in the app. See you in the next part of the series.

Did you find this article valuable?

Support Prateek Sharma by becoming a sponsor. Any amount is appreciated!