Lokalise Flutter SDK and working with over-the-air flow

In this article, you will learn about our new software development kit (SDK) for Flutter, which supports an over-the-air (OTA) localization flow. We will see how to get started with the Flutter SDK and how to work with the OTA endpoints.

The source code is available on GitHub. Find more information about Flutter SDK on ProductHunt.

Check out how our translation management system can help you translate your flutter apps faster.

    Prerequisites

    To follow along, you’ll need the below:

    • Flutter
    • Your favorite code editor (for example, VS Code) with a configured development chain for Flutter
    • Lokalise account and a translation project. If you don’t have an account yet, grab your free trial here.
    • You will also need your Lokalise project ID. To obtain it, open the project in your browser, click More > Settings, and take note of the project ID:
    • Currently the new OTA flow and Flutter SDK are in beta so to gain access to these features, please reach out to our support team using the chat widget in the bottom right corner. Once OTA is out of the beta stage, this step will no longer be necessary

    OTA in 2 minutes

    The term β€œOTA” stands for β€œover the air”. The Lokalise OTA service enables your users to get the latest translations on their mobile applications without you needing to submit a new version to be approved by either the Apple or Google app stores. Basically, over-the-air localization is a lightweight way to instantly update the content in your apps. It’s particularly useful for fixing typos, finishing translations on the fly, and adding languages.

    Imagine the following scenario: You’ve just released a new mobile app with a new feature and a batch of new translations. The app is approved and users can download it from the app stores. However, you understand that one of the translations contains a mistake β€” it’s not crucial but still something that should be fixed as soon as possible.

    Of course, you could release a new version and upload it to the stores but it will probably take a while to get your app reviewed. Instead, you can fix the faulty translation using the Lokalise web editor, generate an over-the-air bundle (an archive with the updated translations), and distribute this bundle to the end users. The bundle will be downloaded automatically when the app is loaded and the new translation will be displayed instead of the incorrect one.

    This significantly cuts down the amount of time and manual work involved, and only requires the initial installation of an over-the-air SDK in the mobile app. Currently, we provide three official SDKs: for Android, iOS, and Flutter. We also provide a public OTA API that you can employ to build a custom SDK.

    So, how does it work?

    The idea is quite simple. Your mobile application should include a Lokalise OTA SDK that does the heavy lifting. When the application is booted on the user’s device, our SDK will automatically connect to the Lokalise over-the-air service and ask if there are any updates available on Lokalise. If updates are found, the corresponding translations will be downloaded and displayed to the user instead of the local ones.

    Please note that the OTA bundle does not (and normally should not) contain all the translations for your app: it includes only the translations modified since the last app release. This way you can ensure that the bundle is slim and that downloading it does not take too much time thus affecting overall performance. To learn more about best practices when working with OTA, please refer to this article.

    Installing and configuring a Flutter app

    To get started, create a new Flutter application in the usual way:

    flutter create to_do_app

    Next, open the pubspec.yaml file and add the necessary dependencies:

    dependencies:
      flutter:
        sdk: flutter
      flutter_localizations:        # <---
        sdk: flutter                # <---
      intl: anyΒ  Β  Β  Β  Β  Β  Β  Β  Β  Β  Β # <---
      lokalise_flutter_sdk: ^1.0.0  # <---

    Install these dependencies by running the following command:

    flutter pub get

    Adding translation files

    Now we need to add translation files to our Flutter app in ARB format.

    First, let’s add the lib/l10n/intl_en.arb file:

    {
        "@@locale": "en",
        "@@last_modified": "2022-10-05T18:53:09+02:00",
        "title": "ToDo App",
        "list_title": "Todo list",
        "addButton": "Add Item",
        "title_addItem": "Add a new todo item",
        "hint_addItem": "Type your new todo",
        "button_addItem": "Add",
        "total_todo": "Total tasks: {count}",
        "pending_todo": "{count, plural, zero {You don't have pending tasks} one {You have just one pending task} other {You have {count} pending tasks}}",
        "completed_todo": "{count, plural, zero {You don't have completed tasks} one {You have just one completed task} other {You have {count} completed tasks}}"
    }

    In this example you can see how to use placeholders ({count}) and how to implement pluralization.

    We’ll also add the lib/l10n/intl_es.arb file with Spanish translations:

    {
        "@@locale": "es",
        "@@last_modified": "2022-10-05T18:53:09+02:00",
        "title": "aplicaciΓ³n ToDo",
        "list_title": "Lista de tareas",
        "addButton": "AΓ±adir tarea",
        "title_addItem": "AΓ±ada una nueva tarea",
        "hint_addItem": "Escriba aqui su nueva tarea",
        "button_addItem": "AΓ±adir",
        "total_todo": "Tareas totales: {count}",
        "pending_todo": "{count, plural, zero {No tienes tareas pendientes} one {Tienes una tarea pendiente} other {Tiene {count} tareas pendientes}}",
        "completed_todo": "{count, plural, zero {No tienes tareas completadas} one {Tienes solo una tarea completada} other {Tienes {count} tareas completadas}}"
    }

    It’s important to generate Dart files from these ARB files, so run the below command:

    dart run lokalise_flutter_sdk:gen-lok-l10n

    The Dart files should appear under the lib/generated directory. Please note that these files are not meant to be edited directly.

    Uploading translation files

    Next, upload the ARB files to your Lokalise project either via the web interface or via the Upload a file endpoint.

    This step is required if you’re going to use OTA later. Also, to see OTA in action, let’s modify one of the translations. For example, modify the English translations for the addButton key and type “Add new item”:

    We’ll see how to deliver this updated translation to the end user without the need to rebuild and republish the app. One last step is to assign a tag to this updated key so that we can easily add it to the OTA bundle later. Thing is, your OTA bundle should be kept as slim as possible and contain only the changes made since the last release. Therefore, click on the “tag” icon next to the key name and type something like “post-v1”:

    Generating an OTA bundle

    Now we have to generate a new OTA bundle containing our modified translations. To achieve that, proceed to the Download page in your Lokalise project. Choose Flutter SDK from the File format dropdown and pick the English language:

    If you don’t see the Flutter SDK format in the dropdown, please reach out to our support team by using the chat widget in the bottom right corner.

    Also make sure to choose post-v1 tag from the Include tags field:

    Once you are ready, scroll to the bottom of the page and click Build only. Your OTA bundle is now generated!

    Obtaining an SDK token

    To request an SDK token, open your Lokalise project and click More > Settings in the top menu:

    Next, find the Lokalise SDK tokens section and click Generate new token:

    Copy the newly generated token and make sure it’s not publicly exposed!

    Managing OTA bundles

    To manage OTA bundles, open your Lokalise project and click More > Settings in the top menu. Next, proceed to the OTA Bundles > Flutter SDK page:

    Here you’ll find all the generated bundles and will be able to manage them. For example, you can click on the “Untitled” text to give your bundle a name.

    Publishing a bundle

    On this page you can also publish a bundle for production or set it for prerelease. The difference between prerelease and production bundles?

    • The production translation bundle will be served to mobile users by default: in other words, the bundle is aimed toward stable app versions.
    • The prerelease bundle will be served to your users only if the mobile SDK specifically requests a prerelease version. It’s usually utilized for testing purposes.

    For example, if you have two different OTA bundles, you can toggle the Production and Prerelease switches:

    Please note that there can be only one bundle published to production. For more fine-tuned control, you can create bundle freezes as explained in this guide.

    Finalizing the Flutter app

    Initializing SDK

    Now let’s open the lib/main.dart file and add these imports:

    import 'package:flutter/material.dart';
    import 'package:lokalise_flutter_sdk/lokalise_flutter_sdk.dart';
    import 'package:to_do_app/app/app.dart';

    Let’s also code the main function:

    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Lokalise.init(
        sdkToken: 'YOUR_SDK_TOKEN', // <--- 1
        projectId: '', // <--- 2
        preRelease: false, // <--- 3
        appVersion: "0", // <--- 4
      );
      runApp(const App());
    }

    Main points to note:

    1. Provide your SDK token (not an API or JWT!) obtained in the previous step here.
    2. Enter your project ID.
    3. Set the preRelease to false as we’ve published our bundle to production.
    4. This option should be provided only if you are using bundle freezes and would like to explicitly set the app version (or when automatic detection is not possible β€” for example, in Web apps).

    Adding supported locales

    Next, create the lib/app/app.dart file:

    import 'package:flutter/material.dart';
    import 'package:to_do_app/app/views/screens/todo_screen/todo_screen.dart';
    import 'package:to_do_app/l10n/generated/l10n.dart';
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          onGenerateTitle: (context) => Lt.of(context).title,
          theme: ThemeData(
            primarySwatch: Colors.blueGrey,
          ),
          localizationsDelegates: Lt.localizationsDelegates,
          supportedLocales: Lt.supportedLocales,
          home: TodoScreen(),
        );
      }
    }

    Your app’s class should contain localizationsDelegates and supportedLocales. Also note the usage of Lt.of(context).{keyName} to retrieve the translations.

    Adding a screen

    Also let’s create the lib/app/views/screens/todo_screen/todo_screen.dart file:

    import 'package:flutter/material.dart';
    import 'package:lokalise_flutter_sdk/lokalise_flutter_sdk.dart';
    import 'package:to_do_app/app/models/todo_model.dart';
    import 'package:to_do_app/app/views/custom_widgets/loading.dart';
    import 'package:to_do_app/app/views/screens/todo_screen/widgets/add_todo_button.dart';
    import 'package:to_do_app/app/views/screens/todo_screen/widgets/todo_section.dart';
    import 'package:to_do_app/l10n/generated/l10n.dart';
    
    class TodoScreen extends StatefulWidget {
      final List<TodoModel> _todos = [];
    
      TodoScreen({super.key});
    
      @override
      State<TodoScreen> createState() => _TodoScreen();
    }
    
    class _TodoScreen extends State<TodoScreen> {
      bool _isLoading = true;
    
      @override
      void initState() {
        super.initState();
        Lokalise.instance.update().then( // <---------------------
              (response) => setState(() => _isLoading = false),
              onError: (error) => setState(() => _isLoading = false),
            );
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text(Lt.of(context).list_title)),
          floatingActionButton: AddTodoButton(onAddPress: _onAddTodo),
          body: _isLoading
              ? const Loading()
              : SafeArea(
                  child: _buildBody(context),
                ),
        );
      }
    
      Widget _buildBody(BuildContext context) {
        final completed = widget._todos.where((e) => e.completed).toList();
        final pending = widget._todos.where((e) => !e.completed).toList();
    
        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20),
          child: Column(
            mainAxisSize: MainAxisSize.max,
            children: [
              Text(
                Lt.of(context).total_todo(widget._todos.length),
                style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 15),
              Flexible(
                child: TodoSection(
                  title: Lt.of(context).pending_todo(pending.length), // <--------
                  todos: pending,
                  onTodoPress: _onTodoPress,
                ),
              ),
              const SizedBox(height: 10),
              Flexible(
                child: TodoSection(
                  title: Lt.of(context).completed_todo(completed.length),
                  todos: completed,
                  onTodoPress: _onTodoPress,
                ),
              ),
            ],
          ),
        );
      }
    
      void _onAddTodo(TodoModel todo) => setState(() => widget._todos.add(todo));
    
      void _onTodoPress(TodoModel todo) =>
          setState(() => todo.completed = !todo.completed);
    }

    Note the usage of Lokalise.instance.update() that shows a loading widget until translations are retrieved.

    Also note that you can pass arguments when retrieving translations  (Lt.of(context).pending_todo(pending.length)). This argument will be used to determine which plural form should be displayed to the user.

    Creating widgets

    Let’s create widgets now. First, lib/app/views/screens/todo_screen/widgets/add_todo_button.dart:

    import 'package:flutter/material.dart';
    import 'package:to_do_app/app/models/todo_model.dart';
    import 'package:to_do_app/l10n/generated/l10n.dart';
    
    class AddTodoButton extends StatelessWidget {
      final Function(TodoModel model) _onAddPress;
    
      const AddTodoButton({
        super.key,
        required Function(TodoModel model) onAddPress,
      }) : _onAddPress = onAddPress;
    
      @override
      Widget build(BuildContext context) {
        return FloatingActionButton(
          tooltip: Lt.of(context).addButton,
          child: const Icon(Icons.add),
          onPressed: () => showDialog(
            context: context,
            builder: (_) => _AddTodoDialog(callback: _onAddPress),
          ),
        );
      }
    }
    
    class _AddTodoDialog extends StatelessWidget {
      final TextEditingController _textFieldController = TextEditingController();
      final Function(TodoModel model) _callback;
    
      _AddTodoDialog({required Function(TodoModel model) callback})
          : _callback = callback;
    
      @override
      Widget build(BuildContext context) {
        return AlertDialog(
          title: Text(Lt.of(context).title_addItem),
          content: TextField(
            controller: _textFieldController,
            decoration: InputDecoration(hintText: Lt.of(context).hint_addItem),
          ),
          actions: [
            TextButton(
              onPressed: _createTodo(context),
              child: Text(Lt.of(context).button_addItem),
            ),
          ],
        );
      }
    
      void Function() _createTodo(BuildContext context) => () {
            final input = _textFieldController.text.trim();
            if (input.isEmpty) {
              return;
            }
    
            Navigator.of(context).pop();
            _callback(TodoModel(title: input));
          };
    }

    Second, lib/app/views/screens/todo_screen/widgets/todo_row.dart:

    import 'package:flutter/material.dart';
    import 'package:to_do_app/app/models/todo_model.dart';
    
    class TodoRow extends StatelessWidget {
      final TodoModel _todo;
      final Function(TodoModel model) _onTodoPress;
    
      const TodoRow({
        super.key,
        required TodoModel todo,
        required Function(TodoModel model) onTodoPress,
      })  : _todo = todo,
            _onTodoPress = onTodoPress;
    
      @override
      Widget build(BuildContext context) {
        return ListTile(
          title: Text(
            _todo.title,
            style: _getTextStyle(_todo.completed),
          ),
          leading: CircleAvatar(child: Text(_todo.title[0])),
          onTap: () => _onTodoPress(_todo),
        );
      }
    
      TextStyle? _getTextStyle(bool checked) => !checked
          ? null
          : const TextStyle(
              color: Colors.black54,
              decoration: TextDecoration.lineThrough,
            );
    }

    And finally lib/app/views/screens/todo_screen/widgets/todo_section.dart:

    import 'package:flutter/material.dart';
    import 'package:to_do_app/app/models/todo_model.dart';
    import 'package:to_do_app/app/views/screens/todo_screen/widgets/todo_row.dart';
    
    class TodoSection extends StatelessWidget {
      final String _title;
      final List<TodoModel> _todos;
      final Function(TodoModel model) _onTodoPress;
    
      const TodoSection({
        super.key,
        required String title,
        required List<TodoModel> todos,
        required Function(TodoModel model) onTodoPress,
      })  : _title = title,
            _todos = todos,
            _onTodoPress = onTodoPress;
    
      @override
      Widget build(BuildContext context) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _buildTitle(),
            Expanded(child: _buildList()),
          ],
        );
      }
    
      Widget _buildTitle() {
        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: 8),
          child: Text(
            _title,
            style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
          ),
        );
      }
    
      Widget _buildList() {
        return Container(
          margin: const EdgeInsets.symmetric(vertical: 10),
          decoration: _listContainerDecoration(),
          child: ListView(
            children: _todos
                .map((e) => TodoRow(todo: e, onTodoPress: _onTodoPress))
                .toList(),
          ),
        );
      }
    
      BoxDecoration _listContainerDecoration() {
        return BoxDecoration(
          border: Border.all(width: 1, color: Colors.grey),
          borderRadius: BorderRadius.circular(15),
          boxShadow: [
            BoxShadow(color: Colors.grey.shade100, spreadRadius: 5, blurRadius: 7),
          ],
        );
      }
    }

    The last step is to create the “loading” widget inside the lib/app/views/custom_widgets/loading.dart file:

    import 'package:flutter/material.dart';
    
    class Loading extends StatelessWidget {
      const Loading({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const IgnorePointer(
          child: Center(
            child: CircularProgressIndicator(),
          ),
        );
      }
    }

    Adding a model

    Let’s also not forget about the model, so add a new lib/app/models/todo_model.dart file:

    class TodoModel {
      final String title;
      bool completed;
    
      TodoModel({required this.title}) : completed = false;
    }

    That’s it! You can also refer to the source code available on GitHub.

    Seeing it in action

    Now you can boot your application with any mobile emulator. OTA translations will be fetched automatically from Lokalise and they will take priority over locally stored translations. Specifically, the tooltip for the “add” button will contain the word “new” that we added in Lokalise.

    For more precise control, you can take advantage of bundle freezes. A bundle freeze is basically a version range for which the bundle should be provided. For instance, you could say that bundle ID 52 should be provided only for customers who are currently using versions 2.0–3.0 of your app. Customers who remain on version 1.0, in turn, should obtain an older bundle (ID 51). To create a bundle freeze, you can use the OTA Bundles page or the Create freeze period endpoint. To learn more about this feature, please refer to the following article.

    Conclusion

    So, in this article we have seen how to get started with Flutter SDK and the OTA localization flow. This feature is still in beta, so if you have any feedback please reach out to us. Also, if you have any further questions, you can always contact our support team. Thank you for staying with me today, and until next time!

    Talk to one of our localization specialists

    Book a call with one of our localization specialists and get a tailored consultation that can guide you on your localization path.

    Get a demo

    Related posts

    Learn something new every two weeks

    Get the latest in localization delivered straight to your inbox.

    Related articles
    Localization made easy. Why wait?