icapps translations

icapps translations

icapps translations

The icapps translations tool is an online tool that we wrote in order to add, edit, delete or verify translations. To make sure we can use these translations we also created plugins, libraries, scripts,... for all the technologies we work with.

TL;DR

You can find the package for handling translations with the translations tool here https://pub.dev/packages/icapps_translations . 

We created a separate package for using localization without the translation tool: https://pub.dev/packages/locale_gen

The main goal of the icapps translations tool is to provide our clients with an easy interface to manage the translations for their app without knowing the development flow.

Developers can download the latest translations at any moment in their project. This is by default always done before a new test or production release in order to avoid delivering a new build with old translations.

In this blog post I, Koen van Looveren, will guide you through my journey of developing the icapps translations tool. 

 

Localizations in Flutter

For every platform we have a plugin or script to easily import the translations. Since we officially provide Flutter as a service for our clients, it is important to have a good foundation on which to build our projects. One of these foundation blocks is the need for an icapps translations plugin.

When we look at the Flutter Documentation itself, it looks like it provides us with a good solution for smaller projects.

 

The pro’s and cons of the Flutter documentation

-

  • No automated support for the icapps translations tool (Obviously)

  • Translations should be added manually

  • All translations for every language will be in memory at any time.

  • No in-app language switch.

  • No direct support for arguments. (String, numbers). => You could write manual functions to provide this functionality. (But who wants to write manual code?)

  • No support for plurals or gender translations

+

  • Type Safety

  • Autocompletion

Conclusion

We followed the documentation so now our code is perfect.

NOOOOOWP.....

It looks like we are facing a lot of issues when we follow the Flutter documentation. Which is very common in mobile development. That is why we should tackle those issues with our icapps_translations plugin.

Mostly we want to autogenerate comprehensive code that will run flawlessly. I chose to use buildrunner and source_gen as plugins to help me with parsing annotations and writing new classes from the annotations. Good examples of packages that use source generation are kiwi or json_serializable.

Now, we still struggled with the problem that we can't use the annotations because everything should be generated from the icapps translations on the server. 

To counter this challenge, I found that it was easier to just build a dart program that could just run from your CLI. This is a simple program that you can run from anywhere. It can access your file system and create or edit new files. Exactly what we need right? So let's get started.

1. Automated icapps translations

The first and most important aspect of this package, is the possibility to download all the translations and bundle them in our project via the asset folder.

  1. Let's modify our pubspec.yaml to make sure the translations are always bundled with a new build.

  1. Secondly we want to have a new configuration so every app can specify its own. api key, default language and all supported languages. The easiest way possible was to use the pubspec.yaml , as Flutter does. We can use a package for reading the pubspec.yaml file, or any yaml file, called yaml. It is very user friendly and a perfect fit for this use case. So I predefined some configurations to make sure the package can work flawlessly. This is everything you need to configure in the pubspec.yaml:

icapps_translations:
 
  api_key: 'enter-your-api-key'
 
  default_language: 'nl'
 
  languages: ['en', 'nl']

Once I could read all the configuration options from the pubspec.yaml. I was able to download the files from the icapps translations tool with the simple http library.

3. After downloading I moved them to the correct position with the correct name:

 assets/locale/{language}.json

Now that I had downloaded all the translations I still wasn’t able to use them as described in the documentation. That is why I had to autogenerate a couple of files.

  1. Localization => This one will be responsible for the type safety, autocompletion and arguments. (Jump to 2. Automatic support for type safety for the new translations)

  2. LocalizationsDelegate => This one will be partially responsible for the language switch but the most important is that it is used by the Flutter framework itself. (Jump to 3. All languages in memory)

2. Automatic support for type safety for the new translations

We don't want to add all the translations from the Localization file. But we can autogenerate all these typesafe functions to get the correct translations every time.

  1. In the previous section we defined some configuration options in the pubspec.yaml. One of the options was default_language. This is the one that we will be using here.

  2. The default language will be used for generating all the functions for retrieving the translations. We loop over all the keys of assets/locale/{default_language}.json.

  3. We create a function for each key. We use camelCase for each function. So real_label_1 will result in a function realLabel1.

  4. To finish up this is the generated file we end up with.

 import 'dart:convert';
 
 
 
import 'package:flutter/services.dart';
 
import 'package:flutter/widgets.dart';
 
 
 
//============================================================//
 
//THIS FILE IS AUTO GENERATED. DO NOT EDIT//
 
//============================================================//
 
class Localization {
 
  Map<dynamic, dynamic> _localisedValues;
 
 
 
  static Localization of(BuildContext context) => Localizations.of<Localization>(context, Localization);
 
 
 
  static Future<Localization> load(Locale locale) async {
 
    final localizations = Localization();
 
    final jsonContent = await rootBundle.loadString('assets/locale/${locale.languageCode}.json');
 
    final Map<String, dynamic> values = json.decode(jsonContent);
 
    localizations._localisedValues = values;
 
    return localizations;
 
  }
 
 
 
  String _t(String key, {List<dynamic> args}) {
 
    try {
 
      String value = _localisedValues[key];
 
      if (value == null) return '⚠$key⚠';
 
      return value;
 
    } catch (e) {
 
      return '⚠$key⚠';
 
    }
 
  }
 
 
 
  String get label1 => _t(‘label1);
 
 
 
  String get label2 => _t(‘label2’);
 
 
 
  String get label3 => _t(‘label3’);
 
 
 
  String get label4 => _t(‘label’4);
 
 
 
}

3. All languages in memory

One big challenge I had with the Flutter documentation itself, is that all the translations for every language you support are loaded in memory and kept in memory for the whole lifetime of the app. When you have 100 translations and only 1 language, there is no problem. When you have 100 translations and 2 language it is doable. But from the moment you have thousands of translations and 3 or more languages it feels like something is wrong. With the icapps translations plugin, we are using the downloaded json files instead of the variables only, so only the correct language is loaded into memory. You can see in the previous section that we added another load function. This will only load 1 language at the same time.

 
...
 
  static Future<Localization> load(Locale locale) async {
 
    final localizations = Localization();
 
    final jsonContent = await rootBundle.loadString('assets/locale/${locale.languageCode}.json');
 
    final Map<String, dynamic> values = json.decode(jsonContent);
 
    localizations._localisedValues = values;
 
    return localizations;
 
  }
 
...  

As the user changes his system language we also want to change our app language to make sure the correct translations are used. In our configuration we added a string array to indicate the supported_languages. This will be used by the plugin to generate the LocalizationDelegate and add them to a variable that can be applied at runtime. Also we provide the defaultLocale in case a developer needs this field.

 
import 'dart:async';
 
 
 
import 'package:device_kast_manager/util/locale/localization.dart';
 
import 'package:flutter/material.dart';
 
 
 
//============================================================//
 
//THIS FILE IS AUTO GENERATED. DO NOT EDIT//
 
//============================================================//
 
class LocalizationDelegate extends LocalizationsDelegate<Localization> {
 
  static const defaultLocale = Locale('nl');
 
  static const _supportedLanguages = [
 
    'nl',
 
    'en',
 
  ];
 
 
 
  static const supportedLocales = [
 
    Locale('nl'),
 
    Locale('en'),
 
  ];
 
 
 
  @override
 
  bool isSupported(Locale locale) => _supportedLanguages.contains(locale.languageCode);
 
 
 
  @override
 
  Future<Localization> load(Locale locale) async => Localization.load(locale);
 
 
 
  @override
 
  bool shouldReload(LocalizationsDelegate<Localization> old) => true;
 
 
 
}

Before we added the supportedLocales we had to specify them with your MaterialApp widget as shown below: Now that we have generated all of that code we can just use a static field to access the supported locales.

MaterialApp(
 
  localizationsDelegates: [
 
    LocalizationsDelegate(),
 
    GlobalMaterialLocalizations.delegate,
 
    GlobalWidgetsLocalizations.delegate,
 
  ],
 
  supportedLocales: const [
 
    Locale('nl'),
 
    Locale('en'),
 
  ],
 
)

Because this is autogenerated, we make sure the supportedLocales are always added to the MaterialApp as well.

4. In App Language switch

For many of our clients it is important to have an in-app language switcher. I could not find any good documentation on this topic so I just started working on a solution until I got something I was happy with.

Because we autogenerated the LocalizationDelegate in step 3 it is easy for us to make sure a language is only reloaded when really needed or when the language switches. We added a newLocale and an activeLocale variable. This gives us the possibility to use a language chosen by the user or let user switch back to the system language.

import 'dart:async';
 
 
 
import 'package:device_kast_manager/util/locale/localization.dart';
 
import 'package:flutter/material.dart';
 
 
 
//============================================================//
 
//THIS FILE IS AUTO GENERATED. DO NOT EDIT//
 
//============================================================//
 
class LocalizationDelegate extends LocalizationsDelegate<Localization> {
 
  static const _defaultLocale = Locale('nl');
 
  static const _supportedLanguages = [
 
    'nl',
 
    'en',
 
  ];
 
 
 
  static const supportedLocales = [
 
    Locale('nl'),
 
    Locale('en'),
 
  ];
 
 
 
  Locale newLocale;
 
  Locale activeLocale;
 
 
 
  LocalizationDelegate({this.newLocale}) {
 
    if (newLocale != null) {
 
      activeLocale = newLocale;
 
    }
 
  }
 
 
 
  @override
 
  bool isSupported(Locale locale) => _supportedLanguages.contains(locale.languageCode);
 
 
 
  @override
 
  Future<Localization> load(Locale locale) async {
 
    activeLocale = newLocale ?? locale;
 
    return Localization.load(activeLocale);
 
  }
 
 
 
  @override
 
  bool shouldReload(LocalizationsDelegate<Localization> old) => true;
 
 
 
}

The activeLocale can be used in your MaterialApp widget but in order to use it here we need to create 1 viewmodel (Provider) and 1 repository. To make sure we save our state and survive app restarts. For our viewmodel we use the provider package. This makes it easy to rebuild the whole widget with the new localizations.

 
import 'package:myapp/repository/locale_repository.dart';
 
import 'package:myapp/util/locale/localization_delegate.dart';
 
import 'package:flutter/material.dart';
 
 
 
class LocaleViewModel with ChangeNotifier {
 
  final LocaleRepository _localeRepository;
 
  var localeDelegate = LocalizationDelegate();
 
 
 
  LocaleViewModel(this._localeRepository);
 
 
 
  void init() {
 
    initLocale();
 
  }
 
 
 
  Future<void> initLocale() async {
 
    final locale = await _localeRepository.getCustomLocale();
 
    if (locale != null) {
 
      localeDelegate = LocalizationDelegate(newLocale: locale);
 
      notifyListeners();
 
    }
 
  }
 
 
 
  Future<void> onSwitchToDutch() async {
 
    await _onUpdateLocaleClicked(const Locale('nl'));
 
  }
 
 
 
  Future<void> onSwitchToEnglish() async {
 
    await _onUpdateLocaleClicked(const Locale('en'));
 
  }
 
 
 
  Future<void> onSwitchToSystemLanguage() async {
 
    await _onUpdateLocaleClicked(null);
 
  }
 
 
 
  Future<void> _onUpdateLocaleClicked(Locale locale) async {
 
    await _localeRepository.setCustomLocale(locale);
 
    localeDelegate = LocalizationDelegate(newLocale: locale);
 
    notifyListeners();
 
  }
 
}

The local repository will be used when a user switches to another language. When the app restarts, the previously selected language still has to be active. We save the user's selection in shared preferences.

import 'package:myapp/util/device_util.dart';
 
import 'package:flutter/widgets.dart';
 
import 'package:shared_preferences/shared_preferences.dart';
 
 
 
class LocaleRepository {
 
  static const STORE_LOCALE = 'locale';
 
 
 
  LocaleRepository();
 
 
 
  Future<void> setCustomLocale(Locale locale) async {
 
    if (!DeviceUtil.isMobile()) return; //saving locale on desktop is not supported
 
    final prefs = await SharedPreferences.getInstance();
 
    if (locale == null) {
 
      print('Reset custom locale. Use system language');
 
      await prefs.remove(STORE_LOCALE);
 
      return;
 
    }
 
    await prefs.setString(STORE_LOCALE, locale.languageCode);
 
  }
 
 
 
  //can be null
 
  Future<Locale> getCustomLocale() async {
 
    if (!DeviceUtil.isMobile()) return null; //getting locale on desktop is not supported
 
    final prefs = await SharedPreferences.getInstance();
 
    final localeCode = prefs.getString(STORE_LOCALE);
 
    if (localeCode == null || localeCode.isEmpty) return null;
 
    return Locale(localeCode);
 
  }
 
}

To finish all this we need to update our MaterialApp:

ChangeNotifierProvider<LocaleViewModel>(
    child: Consumer<LocaleViewModel>(
      builder: (context, value, child) => MaterialApp(
        localizationsDelegates: [
          value.localeDelegate,
          GlobalMaterialLocalizations.delegate,
          GlobalWidgetsLocalizations.delegate,
          FallbackCupertinoLocalisationsDelegate.delegate,
        ],
        locale: value.localeDelegate.activeLocale,
        supportedLocales: LocalizationDelegate.supportedLocales,
      ),
    ),
    builder: (context) => LoginViewModle(LocaleRepository()), // this should be injected with something like kiwi
  );

Our app will be rebuilt when switching to another language.

image of https://github.com/icapps/flutter-icapps-translations

5. Support for arguments

Support for arguments is a difficult one because we want type safety and add support for arguments. We came up with a solution:

Our autogenerated Localization file has changed to and got an extra function. _replaceWith

 String _t(String key, {List<dynamic> args}) {
    try {
      String value = _localisedValues[key];
      if (value == null) return '⚠$key⚠';
      if (args == null || args.isEmpty) return value;
      args.asMap().forEach((index, arg) => value = _replaceWith(value, arg, index + 1));
      return value;
    } catch (e) {
      return '⚠$key⚠';
    }
  }
 
  String _replaceWith(String value, arg, argIndex) {
    if (arg == null) return value;
    if (arg is String) {
      return value.replaceAll('%$argIndex\$s', arg);
    } else if (arg is num) {
      return value.replaceAll('%$argIndex\$d', '$arg');
    }
    return value;
  }

As you can see we have support for strings and numbers. And also support for arguments that are used at multiple positions in the translations.

  • %1$s will be used with strings

  • %1$d will be used with numbers

The number indicates which argument should be used. 1 -> argument 1, 2 -> argument 2,...

This is the same as for Android.

6. Your free plural bonus.

Because we have support for arguments you can now create your own plurals, gender classes.

import 'package:myapp/util/locale/localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
 
class LocalizationPlurals {
  final Localization _localization;
 
  LocalizationPlurals(this._localization);
 
  LocalizationPlurals.of(BuildContext context) : _localization = Localization.of(context);
 
  String getYears(int amount) {
    if (amount == 1) {
      return _localization.dateFormatYear;
    }
    return _localization.dateFormatYears(amount);
  }
}

How can I run this program?

To execute this dart program you can use flutter packages pub run icapps_translations or pub run icapps_translations.

flutter:
  assets:
    - assets/locale/

But we don't need the icapps translations for our project...

What I told you just now is a fully working and active plugin for our internal projects. So does this mean it has no use for you? Not at all! 

I have created my own package that does exactly the same thing. But instead of using the icapps translations, it will generate everything from your default translations located in your assets.

https://pub.dev/packages/locale_gen

It works exactly the same. 

I hope this blog post can come in handy. You can always share it on social media via the buttons below. Please let us know if you have any problems. Good luck!