Written by Wojciech Bakłażec
Published April 11, 2022

Flutter of many flavors – preparing a project for multiple environments

Flutter is a great framework to create mobile apps for iOS and android systems. There are lots of examples and an awesome community that help you to start your brand new project. You are not left alone in the darkness of a new framework, especially when you are just starting with a new technology.

You wrote your first components, prepared the routing, and connected with the backend, now is the right time to parametrize your app to be able to work with more than one environment. For most of the projects, having at least 2 separate environments – one testing and a second for clients – is a must. This means that your app will have to handle connections to different APIs and read specific configurations at the compile time.

The separation of analytics and debugging tools is also a good idea so that you do not pollute your client’s data with your tests. If you decide to use Firebase for that purpose, you will have to prepare environment specific Google services files.

 

The idea

 

In this article, I will focus on two of the most basic problems of a multi-environment app:

  • Reading configuration files specific for the environment
  • Creating plugin specific files on the fly to smoothly switch between different environments

 

The implementation

 

Reading configuration files

Let’s start with the configuration files. The idea is quite simple, we need to be able to pass a compile-time variable to the command line, read it from the dart code, and then load an environment specific configuration file.

Starting from Flutter 1.17, you can define such variables using the following syntax:

flutter run --dart-define=VARIABLE

Let’s use it to read proper configuration files. In the main folder of your flutter repository, create files using the following convention:  `.env.<environment-name>`, e.g.:

Folder structure example:

├── android/
├── ios/
├── lib/
├── .env.development
├── .env.staging
├── .env.production

 

To be able to access files from the Dart code, register them in the pubspec.yaml

file: pubspec.yaml 

flutter:
  assets:
    - .env.development

    - .env.staging

    - .env.production

Remember to add the .env file as an entry in your .gitignore, if it isn’t already, unless you want it included in your version control.

Now, let’s start to parametrize your application. I’ll use the flutter_dotenv package for that purpose. First, you need to load the configuration file based on your environment name. To do that, add the following lines to your main.dart

file: lib/main.dart

import 'package:flutter_dotenv/flutter_dotenv.dart';

Future main() async {

    const environment = String.fromEnvironment('FLAVOR', defaultValue: 'development');

    await dotenv.load(fileName: '.env.$environment');

    //...runapp
}

Now, you are able to access the content of your .env.<environment-name> file from the Dart code:

import 'package:flutter_dotenv/flutter_dotenv.dart';

dotenv.env['VARIABLE_NAME'];

Let’s run it:

flutter run --dart-define FLAVOR="development"

Now, you are able to access the proper configurations from the Dart code and act on them depending on the environment. It’s also good practice to prepare a. env.example file including the parameter names for the developers to know which values need to be prepared in order to run the project.

If you are using Visual studio code, you can edit the .vscode/launch.json file to start your code from debug tools and smoothly switch between environments:

file: .vscode/launch.json

{

  "version": "0.2.0",

  "configurations": [

    {

      "name": "DEVELOPMENT",

      "request": "launch",

      "type": "dart",

      "args": [

        "--dart-define",

        "FLAVOR=development"

      ],

      "flutterMode": "debug"

    },

    ...other environments

  ]

}

Creating plugin specific files on the fly

When you start preparing your project to handle multiple environments, you can encounter problems with plugin configuration files. For some of them, you will have to paste files into system specific folders such as ios/Runner/GoogleService-Info.plist or android/app/google-services.json. That raises a series of problems. First, you don’t want to manually switch files when you run or build your app for different flavors. Second, keeping them in your repository is not a good idea.

To solve that problem, I found a way to run bash scripts from the system build scripts to create files on the fly from the content of .env files.

Let’s start with bash script preparations. In the main folder of your projects, create a scripts folder containing 2 bash scripts: configure_project_android.sh and configure_project_ios.sh, e.g.:

Folder structure example:

├── android/
├── ios/
├── lib/
├── scripts/
│   ├── configure_project_android.sh
│   └── configure_project_ios.sh
├── .env.development
├── .env.staging
├── .env.production

 

Remember to make these files executable, e.g.:

chmod +x configure_project_android.sh

Files encoding

One more step is required to prepare the files to be read from .env files. To hold file content in the .env variable, we need to encode the file and keep it as a string value. If you are running your project on a mac, use the following command:

cat <file-path> | base64 | pbcopy

The command reads the file content, encodes it to base64, and then copies the value to the clipboard. Paste the content of the clipboard to the .env file.

file: .env.development

ENCODED_FILE_KEY=content-of-the-encoded-file

Now, let’s go to the platform specific configurations.

Android

Head over to android/app/build.gradle file and paste the following lines after the dependencies section:

file: android/app/build.gradle

def runConfigurationScript() {

  def environmentVariables = []

  if (project.hasProperty('dart-defines')) {

    environmentVariables = project.property('dart-defines').split(',').collectEntries { entry ->

        def pair = new String(entry.decodeBase64(), 'UTF-8').split('=') 
        [(pair.first()): pair.last()]

    }

  }

  exec {

    commandLine 'sh', '../../scripts/configure_project_android.sh', "${environmentVariables.FLAVOR}"

  }

}


afterEvaluate {

  runConfigurationScript()

}

The runConfigurationScript function reads the content of the dart-defines variable from the command line and then executes the configuration script, passing flavor as an argument. The Gradlew build cycle is used to hook function the execution into the afterEvaluate step.

iOS

First, add the following to your ios/Runner/Info.plist file in order to be able to access –dart-defines command  line content:

<key>DART_DEFINES</key>

<string>$(DART_DEFINES)</string>

Now, go to Xcode -> Runner -> Targets Runner -> Build Phases and add the new Run Script Phase with the following content:

variables="$DART_DEFINES"

for i in ${variables//,/ }
do
    export $(echo "$i" | base64 --decode | sed 's/#.*//g' | xargs)
done

bash ../scripts/configure_project_ios.sh "$FLAVOR"

And finally

We got to the same point for Android and iOS systems. Now, we are able to run our custom bash script as a part of an app build and pass the environment name from the –dart-define variable. Let’s use it to create config files on the fly:

file: configure_project_android.sh

#!/bin/bash

if [ -f "../../.env.$1" ]; then

  export $(cat "../../.env.$1" | sed 's/#.*//g' | xargs)

fi

echo "$ENCODED_FILE_KEY" | base64 --decode > "filename.txt"

Script reads .env.<environment-name> file (where $1 holds the passed environment name from gradle or xcode scripts), loads its content, decodes file and creates a final file.

That’s it! Of course, this is only the tip of the iceberg of the app flavorizing subject, but now you have a toolset that you can build on.

Written by Wojciech Bakłażec
Published April 11, 2022