Software localization

Flutter Over-the-Air Translation with Phrase

Stop waiting for mobile store approval and get fresh content to your Flutter app users instantly across the globe—with Phrase Over the Air.
Software localization blog category featured image | Phrase

Nothing is quite as frustrating as having shiny new content ready to ship for your mobile app, only to have to wait for App or Play Store approval to get it live to your users (I’m especially looking at you, Apple).

With Phrase Over the Air, you can pull fresh translations into your live app with a single click. No version release, no store approval, no fuss. Couple that with the fluent developer experience and multi-platform targeting of Flutter, and you’re shipping features and content to your audience at warp speed.

In this hands-on guide, we’ll localize a small Flutter app, connect it to the Phrase CLI, then to Phrase Over the Air (OTA). Feel free to skip ahead to any section as you see fit.

Our demo app

Our modest little testbed of an app is called the Heroes of Computer Science. We first introduced this app in A Guide to Flutter Localization, which is an in-depth Flutter i18n tutorial. We’ll go over the app very briefly here.

🔗 Resource » Get the full code of our demo app on GitHub.

Demo app | PhraseHere’s Heroes

As you can imagine, the app is quite simple:

.

└── lib/

    ├── main.dart (MaterialApp)

    └── features/

        └── heroes/

            ├── hero_list.dart (HeroList)

            └── hero_card.dart (HeroCard)

import 'package:flutter/material.dart';

import 'package:flutter_phrase_ota_2021/features/hereos/hero_list.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      title: 'Heroes of Computer Science',

      theme: ThemeData(

        primarySwatch: Colors.blue,

      ),

      home: HeroList(title: 'Heroes of Computer Science'),

    );

  }

}

import 'package:flutter/material.dart';

import './hero_card.dart';

class HeroList extends StatelessWidget {

  final String title;

  HeroList({this.title = ''});

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(

        title: Text(title),

      ),

      body: Padding(

        padding: const EdgeInsets.all(16),

        child: Column(

          children: [

            Padding(

              padding: const EdgeInsets.only(bottom: 8.0),

              child: Text('6 Hereos'),

            ),

            Expanded(

              child: ListView(

                children: <Widget>[

                  HeroCard(

                    name: 'Grace Hopper',

                    born: '9 December 1906',

                    bio: 'Devised theory of machine-independent...',

                    imagePath: 'assets/images/grace_hopper.jpg',

                  ),

                  HeroCard(

                    name: 'Alan Turing',

                    born: '23 June 1912',

                    bio: 'Father of theoretical computer science...',

                    imagePath: 'assets/images/alan_turing.jpg',

                  ),

                  // ...

                ],

              ),

            ),

          ],

        ),

      ),

    );

  }

}

import 'package:flutter/material.dart';

class HeroCard extends StatelessWidget {

  final String name;

  final String born;

  final String bio;

  final String? imagePath;

  final String placeholderImagePath = 'assets/images/placeholder.jpg';

  const HeroCard({

    Key? key,

    this.name = '',

    this.born = '',

    this.bio = '',

    this.imagePath,

  }) : super(key: key);

  @override

  Widget build(BuildContext context) {

    var theme = Theme.of(context);

    return Card(

      child: Padding(

        padding: const EdgeInsets.all(4.0),

        child: Row(

          crossAxisAlignment: CrossAxisAlignment.start,

          children: <Widget>[

            Padding(

              padding: const EdgeInsets.only(right: 8.0),

              child: ClipRRect(

                borderRadius: BorderRadius.circular(2),

                child: Image.asset(

                  imagePath ?? placeholderImagePath,

                  width: 100,

                  height: 100,

                ),

              ),

            ),

            Expanded(

              child: Column(

                crossAxisAlignment: CrossAxisAlignment.start,

                children: <Widget>[

                  Padding(

                    padding: const EdgeInsets.only(top: 4),

                    child: Text(

                      name,

                      style: theme.textTheme.headline6,

                    ),

                  ),

                  Padding(

                    padding: const EdgeInsets.only(top: 2, bottom: 4),

                    child: Text(

                      born.isEmpty ? '' : 'Born $born',

                      style: TextStyle(

                        fontSize: 12,

                        fontWeight: FontWeight.w300,

                      ),

                    ),

                  ),

                  Text(

                    bio,

                    style: TextStyle(fontSize: 14),

                  ),

                ],

              ),

            ),

          ],

        ),

      ),

    );

  }

}

That’s basically our app in a nutshell. The problem is our strings are currently hard-coded, which is not good for localization. Let’s internationalize this puppy.

🗒 Note » If you want to code along with us from this point, clone the start branch of our companion repo.

Versions used

We’re using the following language, framework, and package versions in this article:

  • Dart SDK 2.13.4
  • Flutter 2.2.3
  • flutter_localizations (version seems tied to Flutter) — provides localizations to common widgets, like Material or Cupertino widgets.
  • intl 0.17.0 — the backbone of the localization system; allows us to create and use our own localizations; used for formatting dates and numbers; needed by Phrase Flutter SDK.
  • phrase 1.0.0 — Phrase Flutter SDK; allows us to connect to Phrase OTA.
  • flutter_dotenv 5.0.0 — facilitates usage of .env config files so we can keep secret keys out of our Git repo.

Localizing our Flutter app

Ok, let’s pull in the powerful, official Flutter localization package to get to localizing our app quickly.

🗒 Note » If you already have a localized Flutter app, feel free to skip to Over-the-Air Translations with Phrase. Just make sure you’ve localized with intl ^0.17.0 and flutter_localizations and you should be good to go.

Installing packages

We’ll update our pubspec.yaml to install the packages.

# ...

dependencies:

  flutter:

    sdk: flutter

  flutter_localizations:

    sdk: flutter

  intl: ^0.17.0

# ...

Saving pubspec.yaml should trigger the IDE to install the new packages automatically. If that doesn’t work we can open a terminal window, navigate to our project root, and run the following.

flutter pub get

✋🏽 Heads up » The Phrase Flutter SDK, which connects to Phrase OTA, requires version 0.17.0 of the intl package, so make sure you use this version in your pubspec.yaml.

The Flutter localization package uses code generation, creating strongly-typed Dart files that correspond to our translation files. To enable this code generation, we need to add one more line to our pubspec.yaml.

# ...

flutter:

  generate: true

# ...

🔗 Resource » If you want to learn more about how the Flutter localization library works check out our article, A Guide to Flutter Localization.

Configuration

We need to add a file that lets the Flutter localization library know where to find our translation files and generate its Dart code.

# Where to find translation files

arb-dir: lib/l10n

# Which translation is the default/template

template-arb-file: app_en.arb

# What to call generated dart files

output-localization-file: app_localizations.dart

🔗 Resource » The official Internationalization User Guide covers many more options that can go in l10n.yaml to control the Flutter i18n code generator.

Configuring our iOS app

One more step is needed for iOS here: if we don’t add our supported locales to the iOS Info.plist, things might not work as expected.

<dict>

	<key>CFBundleDevelopmentRegion</key>

	<string>$(DEVELOPMENT_LANGUAGE)</string>

    <key>CFBundleLocalizations</key>

	<array>

		<string>en</string>

		<string>ar</string>

	</array>

	<key>CFBundleExecutable</key>

	<string>$(EXECUTABLE_NAME)</string>

    <!-- ... -->

We’ll support English and Arabic for our demo. Of course, you can add the languages you want to support here.

Adding translation files

Next, we’ll need to add our translation ARB (Application Resource Bundle) files. These will be used by the Flutter localization library to generate some Dart code for us. ARB files just contain plain old JSON key/value pairs. We’ll copy all the hard-coded strings in our app to our translation files.

{

  "appTitle": "Heroes of Computer Science",

  // ...

  "hopperName": "Grace Hopper",

  "hopperBio": "Devised theory of machine-independent programming languages.",

  "turingName": "Alan Turing",

  "turingBio": "Father of theoretical computer science & artificial intelligence.",

  // ...

}

{

  "appTitle": "أبطال علوم الكمبيوتر",

  // ...

  "hopperName": "جريس هوبر",

  "hopperBio": "إبتكرت نظرية للغات البرمجة المستقلة عن الجهاز.",

  "turingName": "آلان تورينج",

  "turingBio": "الأب الروحي لعلوم الكمبيوتر النظرية والذكاء الاصطناعي.",

  // ...

}

Again, we’ve added English and Arabic translations for our demo. Feel free to use our locales or your own here.

🗒 Note » We need to make sure that our folder and file names match the config we placed in l10n.yaml above.

Code generation

With our translations in place, it’s time to get some code generation going. First, let’s configure our MaterialApp for localization.

import 'package:flutter/material.dart';

import 'package:flutter_localizations/flutter_localizations.dart';

import 'package:flutter_phrase_ota_2021/features/hereos/hero_list.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      title: 'Heroes of Computer Science',

      localizationsDelegates: [

        GlobalMaterialLocalizations.delegate,

        GlobalWidgetsLocalizations.delegate,

        GlobalCupertinoLocalizations.delegate,

      ],

      supportedLocales: [

        Locale('en', ''),

        Locale('ar', ''),

      ],

      theme: ThemeData(

        primarySwatch: Colors.blue,

      ),

      home: HeroList(title: 'Heroes of Computer Science'),

    );

  }

}

Now we can run the app to generate localization Dart code. After running the app, we should see the following files in our project.

.

└── .dart_tool/

    └── flutter_gen/

        └── gen_l10n/

            ├── app_localizations.dart

            ├── app_localizations_en.dart

            └── app_localizations_ar.dart

If you see the generated files, it worked!

Localizing the app

Ok, let’s clean up our main.dart and localize it. We’ll remove the direct flutter_localizations import and bring in our generated AppLocalizations instead. We’ll also start using our localizations via AppLocalizations.of(context) to bring in our translations.

import 'package:flutter/material.dart';

import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import 'package:flutter_phrase_ota_2021/features/hereos/hero_list.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,

      localizationsDelegates: AppLocalizations.localizationsDelegates,

      supportedLocales: AppLocalizations.supportedLocales,

      theme: ThemeData(

        primarySwatch: Colors.blue,

      ),

      initialRoute: '/',

      routes: {

        '/': (context) =>

            HeroList(title: AppLocalizations.of(context)!.appTitle)

      },

    );

  }

}

You may have noticed that we’re using the onGenerateTitle, initialRoute, and routes properties of MaterialApp instead of title and home. This is because loading the Flutter localization library is an async operation: we won’t have translations available when our MaterialApp is being constructed. We use the handy callback alternatives to provide translations when they’re ready.

Let’s localized our HeroList and HeroCard widgets while we’re at it.

import 'package:flutter/material.dart';

import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import './hero_card.dart';

class HeroList extends StatelessWidget {

  final String title;

  HeroList({this.title = ''});

  @override

  Widget build(BuildContext context) {

    var t = AppLocalizations.of(context)!;

    return Scaffold(

      appBar: AppBar(

        title: Text(title),

      ),

      body: Padding(

        padding: const EdgeInsets.all(16),

        child: Column(

          children: [

            Padding(

              padding: const EdgeInsets.only(bottom: 8.0),

              child: Text(t.heroCount(6)),

            ),

            Expanded(

              child: ListView(

                children: <Widget>[

                  HeroCard(

                    name: t.hopperName,

                    born: '9 December 1906',

                    bio: t.hopperBio,

                    imagePath: 'assets/images/grace_hopper.jpg',

                  ),

                  HeroCard(

                    name: t.turingName,

                    born: '23 June 1912',

                    bio: t.turingBio,

                    imagePath: 'assets/images/alan_turing.jpg',

                  ),

                  // ...

                  ),

                ],

              ),

            ),

          ],

        ),

      ),

    );

  }

}

import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import 'package:intl/intl.dart';

import 'package:flutter/material.dart';

class HeroCard extends StatelessWidget {

  final String name;

  final String born;

  final String bio;

  final String? imagePath;

  final String placeholderImagePath = 'assets/images/placeholder.jpg';

  final DateTime bornDateTime;

  HeroCard({

    Key? key,

    this.name = '',

    this.born = '',

    this.bio = '',

    this.imagePath,

  })  : bornDateTime = DateFormat('d MMMM yyyy').parse(born),

        super(key: key);

  @override

  Widget build(BuildContext context) {

    var t = AppLocalizations.of(context)!;

    var theme = Theme.of(context);

    return Card(

      child: Padding(

        padding: const EdgeInsets.all(4.0),

        child: Row(

          crossAxisAlignment: CrossAxisAlignment.start,

          children: <Widget>[

            Padding(

              // <Already-localiazed image from this.imagePath>...

            ),

            Expanded(

              child: Column(

                crossAxisAlignment: CrossAxisAlignment.start,

                children: <Widget>[

                  Padding(

                    // <Already-localized name from this.name>...

                  ),

                  Padding(

                    padding: const EdgeInsets.only(top: 2, bottom: 4),

                    child: Text(

                      born.isEmpty ? '' : t.heroBorn(bornDateTime),

                      style: TextStyle(

                       fontSize: 12,

                       fontWeight: FontWeight.w300,

                      ),

                    ),

                  ),

                  // <Already-loclalized bio from this.bio>...

                ],

              ),

            ),

          ],

        ),

      ),

    );

  }

}

🔗 Resource » If you’re wondering what the heck the t.heroCount(6) call is doing, read all about it in our Guide to Flutter Localization.

Now when we set the locale of our mobile operating system to Arabic, we can see our translated strings. Hooray 😃.

Translated demo app | PhraseOur app, gloriously globalized

Connecting to Phrase

With our app localized, let’s take it to the next level by connecting it to Phrase.

🗒 Note » If you want to code along with us from this point on, clone the localized branch from our companion GitHub repo.

Creating a Phrase project

🗒 Note » If you have a Phrase project connected to our Flutter app, skip ahead to Over-the-Air Translations with Phrase.

I’m assuming you have a Phrase account at this point. If you don’t, sign up for a free trial.

First, we’ll create a new Phrase project by logging in and going to Projects ➞ Create New Project.

Creating a new project in Phrase | Phrase

This will open the Add Project dialog. Here we’ll enter a project name. Everything other than the project name is optional, but we’ll save a bit of time later if we specify ARB as our main format. When we’re happy with our options, we can click the Save button.

Add Project window in Phrase | Phrase

Next, we’ll get the project setup page. Let’s click the Set up languages button to add our app’s supported languages.

Setting up new languages in Phrase | Phrase

Adding supported app languages | Phrase

We can add as many languages as we want here. The first one will be the default language. After we add our locales, let’s click the Create languages button. We’ll be taken back to the project setup page. This is all the setup we need at this point, so let’s click the Skip setup button to move on.

Skipping the setup | Phrase

At this point, our Phrase project is good to go. While we’re here, let’s get an access token; we’ll need it to wire up our Flutter project to the Phrase project.

Getting a Phrase access token

To generate an access token, let’s head near the top-right of the screen where our name is. Clicking our name opens a dropdown menu with an Access Tokens option, which of course needs a good clicking at the moment.

Access token | Phrase

Generating a new token | Phrase

This opens the Access Tokens page, revealing a Generate Token button. Let’s click that button to open the Generate Token dialog.

Generate token dialog | Phrase

We just need to give our token a note to remember why we made it. We want both the read and write scopes for our project since we’ll be doing two-way sync between our project and Phrase. Pick the options that make sense for your project and click Save to reveal the token.

Revealed token | Phrase

✋🏽 Heads up » Copy the token somewhere safe: once you navigate away from the tokens page you won’t be able to access the token from the Phrase console again.

Installing the CLI

With token in hand, we can hop over to our Flutter project to wire it up to Phrase. We’ll need the Phrase CLI to do this, so please make sure to install it. I’m on macOS and I like to use the Homebrew package manager, so I’ll take that route to install the CLI. I’ll just run the following in a terminal.

brew install phrase

If all goes well, we’ll be presented with something like this:

Successful CLI setup | Phrase

🔗 Resource » If you’re not on macOS or don’t want to use Homebrew, check out the Phrase CLI docs for all the available installation options.

Connecting our Flutter app with Phrase

Now that we have the Phrase CLI installed, we can connect our Phrase project to our Flutter project. Let’s open a command line, navigate to the root of our Flutter project, and run the following.

phrase init

API access token prompt | Phrase

After entering the access token we generated earlier and hitting Enter, we’ll be prompted to select our Phrase project.

Selecting our Phrase project | Phrase

Let’s enter the number of the appropriate project and hit Enter. We’ll then be asked for the format to use. If we selected ARB when we created our Phrase project in the Phrase console, we should be given that format as a default option, and we can just hit Enter. Otherwise, we can select 1. (arb) from the list.

Selecting right file format | Phrase

We’ll now be asked to enter our translation file paths, relative to the project root. These paths use a special placeholder, <locale_name>, that corresponds to the locale codes in our project. For example, app_<locale_name>.arb will tell Phrase to expect app_en.arb and app_ar.arb files, since we added these two locales to our Phrase project.

Expected file formats in Phrase | Phrase

For both source and target file paths, let’s enter ./lib/l10n/app_<locale_name>.arb. You’ll recall that this matches the translation file path in our Flutter project.

Finally, a prompt will ask whether we want to upload our translation files to Phrase for the first time. Let's do that by entering y followed by Enter.

🗒 Note » If you miss the upload step during intialization, just run phrase push from the command line to upload the files.

At this point, if all went well, a .phrase.yml file will have appeared at the root of our project.

✋🏽 Heads up » It’s more secure add .phrase.yml to our .gitignore to keep project access secrets out of our Git repo.

If we chose the upload option earlier, we can navigate to our Phrase project, go to Languages, and open a language to see our translations ready for editing.

Phrase Over The Air menu | Phrase

Over-the-Air Translations with Phrase

At this point, our project can sync translations back and forth with Phrase. We’d still have to create a new version of our mobile app every time we had a translation update, however. This means waiting for app approval and for our users’ phones to update to the latest version of the app. There’s a better way: Over the Air.

How Phrase OTA works

Here’s the skinny: after we connect our project to Phrase OTA, and deploy a new version of our app with OTA connected, the following happens.

  1. A user opens our app, triggering a translation OTA pull in the background.
  2. The next time the user opens the app, they see our new translations. They didn’t have to update to a new version of the app to do this. It happens “over the air”.

Over The Air Flowchart | Phrase

Sound awesome? Then what are we waiting for? Let’s get our project connected to Phrase OTA.

Installing the Phrase Flutter SDK

First, we’ll add the Phrase Flutter package to our project by updating our pubspec.yaml.

# ...

dependencies:

  flutter:

    sdk: flutter

  flutter_localizations:

    sdk: flutter

  intl: ^0.17.0

  phrase: ^1.0.0

# ...

Our IDE should have installed the package automatically. Otherwise, we can open a command line, navigate to our project root, and run the following.

flutter pub get

The Phrase SDK needs to generate some code for us. Let’s ask it to do so by running the following command-line command from the root of our project.

flutter pub run phrase

If all went well we’ll see a nice success message.

Success message | Phrase

✋🏽 Heads up » The Phrase Flutter package installs a Flutter plugin, so you’ll need Cocoapods installed and up to date if you want to run your Flutter project on iOS.

🔗 Resource » More info on our Flutter SDK is available in our help center.

Creating a distribution

To make updated translations available to our app over the air, we’ll need to have an OTA distribution. Let’s log in to our Phrase console and head over to Over the Air.

Over the Air menu | Phrase

Creating a over the air distribution | Phrase

This will open the Over the Air page with a shiny Create distribution button. Let’s click that button to open the Add distribution dialog.

Add distribution dialog | Phrase

We’ll give our distribution a name and connect it to the Phrase project we created earlier. The only platform we’re covering here is Flutter, so we’ll select that as the sole option under Platforms. I’m leaving the defaults under Settings, as they work for me. Feel free to change them to suit your needs.

🗒 Note » You cannot change the Platforms your distribution targets after you’ve created it. You can, however, change the distribution Settings at any time.

Let’s click Save when we’re happy with our configuration to commit our changes and open the distribution details page.

Distribution detail page | Phrase

The three keys, Distribution ID, Development Secret, and Production Secret are important for connecting our Flutter project with this OTA distribution, and we’ll use them momentarily.

🗒 Note » Don’t worry: you can access the distribution keys at any time.

Creating a development release

Ok, now that we have a distribution, we’ll need an OTA release. A release is simply a snapshot of translation updates that we want to make available to our mobile app users.

First, let’s update our translations so that we have something to release. We’ll head over to Projects ➞ <Our Project> ➞ Languages ➞ en, and update the appTitle string.

Updating apptitle string | Phrase

Now we can create a release. Let’s go to ➞ Over the Air and click on the name of our distribution.

Creating a release in distribution details menu | Phrase

The Create release button near the bottom of the page will pop open the Add release dialog.

Add release dialog window | Phrase

For simplicity, let’s make our release available to all users. This is the default, so we don’t need to change anything in the dialog. It’s probably a good idea to add a description for posterity, however. Once satisfied, we can click Save to create the release.

Notice that the Releases section at the bottom of the page has a new row with an unpublished release. Publishing a release makes it available to our production users, so we probably want to keep it unpublished while we test it first.

Unpublished release | Phrase

✋🏽 Heads up » Even for your development environment, you always have to create a release to see your Phrase translation updates reflected over the air in your app.

Adding Phrase to main.dart

Ok, now that we have an OTA release we can see in our app, let’s finish wiring our app up to Phrase OTA. We’ll update our main.dart to initialize the Phrase SDK and make sure we’re using Phrase when accessing our translations.

import 'package:flutter/material.dart';

import 'package:phrase/phrase.dart';

import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import 'package:flutter_gen/gen_l10n/phrase_localizations.dart';

import 'package:flutter_phrase_ota_2021/features/hereos/hero_list.dart';

void main() {

  Phrase.setup(

    // distribution id

    '44****************************ed',

    // development secret

    'in***************************************no',

  );

  runApp(MyApp());

}

class MyApp extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,

      localizationsDelegates: PhraseLocalizations.localizationsDelegates,

      supportedLocales: PhraseLocalizations.supportedLocales,

      theme: ThemeData(

        primarySwatch: Colors.blue,

      ),

      initialRoute: '/',

      routes: {

        '/': (context) =>

            HeroList(title: AppLocalizations.of(context)!.appTitle)

      },

    );

  }

}

This is all we have to do. We can continue accessing our translations as usual with AppLocalizations.of(context). The Phrase SDK will have rewired things behind the scenes so that our translations will get pulled from our OTA releases when appropriate.

Now let’s run the app. Take note of the debug console.

Debug message | Phrase

Phrase didn’t find an OTA translations cache, which is expected. It created one and pulled our translations over the air to populate it. In the meantime, Phrase fell back on our local translations, so we saw no change in the app. Again, this is expected.

Demo app | Phrase

But those shiny new translations are waiting behind the scenes and will appear on the next app run. Don’t believe me? Re-run the app.

Rerun button | Phrase

That’s a full re-run, not a hot reload

Notice that our updated appTitle translation is now showing.

Demo app | Phrase

We didn’t have to do a phrase pull to get these translations into our app.

✋🏽 Heads up » Just like the Flutter localizations package, the Phrase Flutter SDK can trip up your code editor with presumably missing files and tokens. Always try the Debug anyway option when running the app after errors like this to see if your app runs ok. If does then you’re probably fine, and a restart or two of the code editor should make the errors disappear. This is just an unfortunate side effect of code gen at the moment, but no real harm is done.

Alright, that’s OTA basically wired up to our project! Woohoo 🚀!

Hiding our secret keys

We probably don’t want our OTA distribution secrets sitting in our Git repo. It would also be nice if our app would use the appropriate secret depending on the environment it’s running in (development or production). We can solve both those problems with a little config module and a good old .env file.

Let’s start by installing a little package that handles .env file loading and parsing for us. The package in question is flutter_dotenv, and we’ll add it to our pubspec.yaml to install it.

# ...

dependencies:

  flutter:

    sdk: flutter

  flutter_localizations:

    sdk: flutter

  intl: ^0.17.0

  phrase: ^1.0.0

  flutter_dotenv: ^5.0.0

# ...

Adding an .env class

With flutter_dotenv installed, let’s add a .env file to the root of our project to house our app’s secret keys.

PHRASE_OTA_DISTRIBUTION_ID=44************************ed

PHRASE_OTA_DEV_SECRET=in***********************************no

PHRASE_OTA_PRODUCTION_SECRET=EN***********************************gs

You’ll remember that we get our distribution keys from the distribution details page on the Phrase console.

distribution details page on the Phrase console | Phrase

We need to add the .env file to our assets list in pubspec.yaml so that it’s available to our app.

# ...

  assets:

    - .env

    - assets/images/placeholder.jpg

    - assets/images/alan_turing.jpg

## ...

✋🏽 Heads up » Make sure to add your .env file to .gitignore to keep out of your Git repo.

A little Config class

Now let’s create a little wrapper class that loads the .env file and grants us access to the Phrase OTA keys in it.

import 'package:flutter/foundation.dart';

import 'package:flutter_dotenv/flutter_dotenv.dart';

class Config {

  static const String fileName = '.env';

  static Future load() async => await dotenv.load(fileName: fileName);

  static String? get(String key) => dotenv.env[key];

  static String? get phraseOtaDistributionId =>

      get('PHRASE_OTA_DISTRIBUTION_ID');

  // kDebugMode and kRelease mode are constants

  // built into foundation.dart (imported above)

  static String? get phraseOtaSecret {

    if (kDebugMode) {

      return get('PHRASE_OTA_DEV_SECRET');

    } else if (kReleaseMode) {

      return get('PHRASE_OTA_PRODUCTION_SECRET');

    }

  }

}

We now have a clean little config API to load and access our app’s secret keys.

import 'package:flutter/material.dart';

import 'package:phrase/phrase.dart';

import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import 'package:flutter_gen/gen_l10n/phrase_localizations.dart';

import 'package:flutter_phrase_ota_2021/services/config/config.dart';

import 'package:flutter_phrase_ota_2021/features/hereos/hero_list.dart';

// Loading the .env file is an async operation,

// so we need to async-ify our main() function.

Future<void> main() async {

  await Config.load();

  Phrase.setup(

    Config.phraseOtaDistributionId!,

    Config.phraseOtaSecret!,

  );

  runApp(MyApp());

}

class MyApp extends StatelessWidget {

  // ...

}

Our app will now automatically use the OTA development secret in debug mode, and the production secret in release mode. Handy. 😉

Creating a production release

It wouldn’t be of much use to have translations come to our development machines over the air and not to our users. Once we’re happy with a release, we can publish it to our app in the wild for our users’ benefit.

Here’s a release/production version of our app deployed to a physical device.

Demo app | Phrase

Remember that our OTA release updated the app title to be “Comp-Sci Champs”. We’re not seeing that update here because our OTA release isn’t published, so it’s not available for release/production.

Let’s unleash this beast. On the Phrase console, we’ll head over to Over the Air and click on our distribution’s name to open its details page. Scrolling down, we’ll find the Releases section housing a row with our release, and a nice Publish button ready for clicking.

Publishing Comp-Sci champs | Phrase

When we click Publish, we’ll get a confirmation dialog; confirming yields a message that the release is successfully published.

✋🏽 Heads up » For Android, we have to update our application manifest to allow internet access in release mode. Otherwise the Phrase SDK won’t be able to pull in our OTA releases in release/production.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"

    package="com.example.flutter_phrase_ota_2021">

    <uses-permission android:name="android.permission.INTERNET"/>

   <application

     <!-- ... -->

Now when our users restart our app, they’ll get the fresh translations delivered directly from Phrase. No app versioning, no waiting for Apple. Web-like deployment for mobile apps 🏄.

Finished demo app | Phrase

A couple of notes:

  • Phrase OTA will only pull in translations for the active language to preserve bandwidth. This should be seamless to our users, but good to know for us developers.
  • Phrase identifies our app environment as development or production via the development secret or production secret, respectively. This can be handy in development since we can test published production releases by simply switching our key temporarily.

🔗 Resource » Get the full code of our demo app from our companion GitHub repo.

Adios for now

Phrase Over-the-Air translations can save us so much time as we publish new translations directly to our app users. With OTA we also have the added benefit of skipping App Store approval (just for a text update). And Phrase offers much more than OTA, including a multitude of translation file format support; machine translation; GitHub, GitLab, and Bitbucket sync; branching and versioning; and much, much more. Phrase can streamline the localization process for your app end to end. Take a look at all of Phrase’s features and sign up for a free 14-day trial.