It’s quite common for a native Android app to use productFlavors to set up several apps from the same source code. So, how easy is this to implement in Flutter?

In this code tutorial, we will set up a project with 2 app flavors. Each app will display the app name (specific per flavor), the current date (shared functionality, which includes a string), a short description (specific per flavor), and two images (one specific per flavor, one shared).

Note: The code tutorial only involves the Flutter and Android set up, not the iOS set up, because I am not proficient in building and publishing iOS apps.



Code tutorial index

Setting up the app as a single app

Adding flavors (Android setup)

Adding flavors (Flutter setup)

Customising some string resources based on flavor

Customising some assets based on flavor

Setting up the app as a single app

To follow the code tutorial, create a new app as follows.

Create app flutter create flavorsexample 1 flutter create flavorsexample

If you’re unsure how to set up a Flutter app, check out Getting started with Flutter official tutorial.

Firstly, we create a Material app in main.dart, which will launch the HomePage widget.

main.dart import 'package:flutter/material.dart'; import 'home_page.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flavors Example', theme: ThemeData( primaryColor: Color(0xFF43a047), accentColor: Color(0xFFffcc00), primaryColorBrightness: Brightness.dark, ), home: HomePage(), ); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import 'package:flutter/material.dart' ; import 'home_page.dart' ; void main ( ) { runApp ( MyApp ( ) ) ; } class MyApp extends StatelessWidget { // This widget is the root of your application. @ override Widget build ( BuildContext context ) { return MaterialApp ( title : 'Flavors Example' , theme : ThemeData ( primaryColor : Color ( 0xFF43a047 ) , accentColor : Color ( 0xFFffcc00 ) , primaryColorBrightness : Brightness . dark , ) , home : HomePage ( ) , ) ; } }

Secondly, we create home_page.dart. It displays the app name, the current date, a short description, and two images.

home_page.dart import 'package:flutter/material.dart'; import 'package:flavorsexample/resource/display_strings.dart'; import 'package:intl/intl.dart'; class HomePage extends StatefulWidget { HomePage({Key key}) : super(key: key); @override _HomePageState createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Flavors Example"), ), body: _buildBody(), ); } Widget _buildBody() { return Container( margin: EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), child: Column( children: <Widget>[ Text(APP_TITLE), Text(DATE + getDateForDisplay()), Text(APP_DESCRIPTION), Image.asset('assets/dancing.png', width: 50.0, height: 50.0,), Image.asset('assets/1.png', width: 50.0, height: 50.0), ], ) ); } String getDateForDisplay() { DateTime now = DateTime.now(); var formatter = DateFormat('EEEE dd MMMM yyyy'); return formatter.format(now); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import 'package:flutter/material.dart' ; import 'package:flavorsexample/resource/display_strings.dart' ; import 'package:intl/intl.dart' ; class HomePage extends StatefulWidget { HomePage ( { Key key } ) : super ( key : key ) ; @ override _HomePageState createState ( ) = > _HomePageState ( ) ; } class _HomePageState extends State < HomePage > { @ override Widget build ( BuildContext context ) { return Scaffold ( appBar : AppBar ( title : Text ( "Flavors Example" ) , ) , body : _buildBody ( ) , ) ; } Widget _buildBody ( ) { return Container ( margin : EdgeInsets . symmetric ( vertical : 16.0 , horizontal : 16.0 ) , child : Column ( children : < Widget > [ Text ( APP_TITLE ) , Text ( DATE + getDateForDisplay ( ) ) , Text ( APP_DESCRIPTION ) , Image . asset ( 'assets/dancing.png' , width : 50.0 , height : 50.0 , ) , Image . asset ( 'assets/1.png' , width : 50.0 , height : 50.0 ) , ] , ) ) ; } String getDateForDisplay ( ) { DateTime now = DateTime . now ( ) ; var formatter = DateFormat ( 'EEEE dd MMMM yyyy' ) ; return formatter . format ( now ) ; } }

Thirdly, we create a resource folder, and a new file display_strings.dart in in.

resource/display_strings.dart String APP_TITLE = "SingleApp"; String DATE = "Today is "; String APP_DESCRIPTION = "Single App Description blah blah blah"; 1 2 3 String APP_TITLE = "SingleApp" ; String DATE = "Today is " ; String APP_DESCRIPTION = "Single App Description blah blah blah" ;

Fourthly, we add the intl package (used for date formatting) to pubspec.yaml as below, then do flutter packages get .

pubspec.yaml [...] dependencies: flutter: sdk: flutter intl: ^0.15.7 [...] 1 2 3 4 5 6 7 8 9 [ . . . ] dependencies : flutter : sdk : flutter intl : ^ 0.15.7 [ . . . ]

Lastly, we set up the images. Create a folder assets (on same level as lib), then add images dancing.png and 1.png (right click on files then save), and amend the pubspec.yaml as below.

pubspec.yaml [...] flutter: uses-material-design: true assets: - assets/ [...] 1 2 3 4 5 6 7 8 9 10 [ . . . ] flutter : uses - material - design : true assets : - assets / [ . . . ]

Back to code tutorial index

Looking for a Flutter job? Check out my job board dedicated to Flutter at flutterjobs.info

Adding flavors (Android setup)

Now, we will create 2 flavors of our app, called app1 and app2. To do this, we amend android/app/build.gradle file as below.

android/app/build.gradle android { [...] defaultConfig { minSdkVersion 16 targetSdkVersion 27 testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } flavorDimensions "app" productFlavors { app1 { dimension "app" applicationId "com.example.flavorsexample.app1" versionCode 1 versionName "1.0" } app2 { dimension "app" applicationId "com.example.flavorsexample.app2" versionCode 1 versionName "1.0" } } buildTypes { [...] } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 android { [ . . . ] defaultConfig { minSdkVersion 16 targetSdkVersion 27 testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } flavorDimensions "app" productFlavors { app1 { dimension "app" applicationId "com.example.flavorsexample.app1" versionCode 1 versionName "1.0" } app2 { dimension "app" applicationId "com.example.flavorsexample.app2" versionCode 1 versionName "1.0" } } buildTypes { [ . . . ] } }

We also amend the AndroidManifest.xml (in android/app/src/main folder) to use an app name specified in an Android string resource.

android/app/src/main/AndroidManifest.xml <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.flavorsexample"> [...] <application android:name="io.flutter.app.FlutterApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher"> [...] 1 2 3 4 5 6 7 8 9 10 11 < manifest xmlns : android = "http://schemas.android.com/apk/res/android" package = "com.example.flavorsexample" > [ . . . ] < application android : name = "io.flutter.app.FlutterApplication" android : label = "@string/app_name" android : icon = "@mipmap/ic_launcher" > [ . . . ]

Then, we provide the app name string for each app. In android/app/src/main/res/values, we create strings.xml as below.

android/app/src/main/res/values/strings.xml <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">Default App Name</string> </resources> 1 2 3 4 <? xml version = "1.0" encoding = "utf-8" ?> < resources > < string name = "app_name" > Default App Name < / string > < / resources >

Finally, we create folders app1 and app2 under android/app/src. In each new folder, we create a folder res then a folder values. Inside each new values folder, we create strings.xml as below.

android/app/src/app1/res/values/strings.xml <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">App 1</string> </resources> 1 2 3 4 <? xml version = "1.0" encoding = "utf-8" ?> < resources > < string name = "app_name" > App 1 < / string > < / resources >

android/app/src/app2/res/values/strings.xml <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">App 2</string> </resources> 1 2 3 4 <? xml version = "1.0" encoding = "utf-8" ?> < resources > < string name = "app_name" > App 2 < / string > < / resources >

Flutter run and build commands come with a –flavor flag. So, from the command line, you can simply type flutter run --flavor app1 and flutter fun --flavor app2 .

Gotcha 1: As of writing and on my system, you need to do flutter clean in between running different flavors.

Gotcha 2: –flavor flag currently fails if the Android flavor name has an uppercase character in it, so make sure your Android flavor names are all lowercase!

If you prefer using an IDE, you can edit your configurations and pass in the flavor in it. So let’s create 2 configurations in the IDE, as per screenshots below.

At this point, when you run each app, you will see the same thing on screen. However, you should see the correct app name when you view the list of apps on your device (ie “App 1” and “App 2”).

Back to code tutorial index



Adding flavors (Flutter setup)

Following the advice from Separating build environments in Flutter apps, part #1 – environment-specific configuration in Dart side, we will create an app_config.dart file, which is an Inherited Widget. Such a Widget allows for the app config data to be accessed from any widgets in the app.

app_config.dart import 'package:flutter/material.dart'; class AppConfig extends InheritedWidget { AppConfig({this.appDisplayName,this.appInternalId, Widget child}):super(child: child); final String appDisplayName; final int appInternalId; static AppConfig of(BuildContext context) { return context.inheritFromWidgetOfExactType(AppConfig); } @override bool updateShouldNotify(InheritedWidget oldWidget) => false; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import 'package:flutter/material.dart' ; class AppConfig extends InheritedWidget { AppConfig ( { this . appDisplayName , this . appInternalId , Widget child } ) : super ( child : child ) ; final String appDisplayName ; final int appInternalId ; static AppConfig of ( BuildContext context ) { return context . inheritFromWidgetOfExactType ( AppConfig ) ; } @ override bool updateShouldNotify ( InheritedWidget oldWidget ) = > false ; }

Then, we rename main.dart to main_common.dart, amend it slightly, and create main_app1.dart and main_app2.dart files.

main_common.dart import 'package:flutter/material.dart'; import 'home_page.dart'; import 'package:flavorsexample/app_config.dart'; void mainCommon() { // Here would be background init code, if any } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { var config = AppConfig.of(context); return _buildApp(config.appDisplayName); } Widget _buildApp(String appName){ return MaterialApp( title: appName, theme: ThemeData( primaryColor: Color(0xFF43a047), accentColor: Color(0xFFffcc00), primaryColorBrightness: Brightness.dark, ), home: HomePage(), ); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import 'package:flutter/material.dart' ; import 'home_page.dart' ; import 'package:flavorsexample/app_config.dart' ; void mainCommon ( ) { // Here would be background init code, if any } class MyApp extends StatelessWidget { // This widget is the root of your application. @ override Widget build ( BuildContext context ) { var config = AppConfig . of ( context ) ; return _buildApp ( config . appDisplayName ) ; } Widget _buildApp ( String appName ) { return MaterialApp ( title : appName , theme : ThemeData ( primaryColor : Color ( 0xFF43a047 ) , accentColor : Color ( 0xFFffcc00 ) , primaryColorBrightness : Brightness . dark , ) , home : HomePage ( ) , ) ; } }

main_app1.dart import 'package:flavorsexample/app_config.dart'; import 'package:flavorsexample/main_common.dart'; import 'package:flutter/material.dart'; void main() { var configuredApp = AppConfig( appDisplayName: "App 1", appInternalId: 1, child: MyApp(), ); mainCommon(); runApp(configuredApp); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import 'package:flavorsexample/app_config.dart' ; import 'package:flavorsexample/main_common.dart' ; import 'package:flutter/material.dart' ; void main ( ) { var configuredApp = AppConfig ( appDisplayName : "App 1" , appInternalId : 1 , child : MyApp ( ) , ) ; mainCommon ( ) ; runApp ( configuredApp ) ; }

main_app2.dart import 'package:flavorsexample/app_config.dart'; import 'package:flavorsexample/main_common.dart'; import 'package:flutter/material.dart'; void main() { var configuredApp = AppConfig( appDisplayName: "App 2", appInternalId: 2, child: MyApp(), ); mainCommon(); runApp(configuredApp); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import 'package:flavorsexample/app_config.dart' ; import 'package:flavorsexample/main_common.dart' ; import 'package:flutter/material.dart' ; void main ( ) { var configuredApp = AppConfig ( appDisplayName : "App 2" , appInternalId : 2 , child : MyApp ( ) , ) ; mainCommon ( ) ; runApp ( configuredApp ) ; }

Then, we edit home_page.dart to show the app name. We also remove APP_TITLE string from display_strings.dart.

home_page.dart [...] import 'package:flavorsexample/app_config.dart'; class HomePage extends StatefulWidget { [...] } class _HomePageState extends State<HomePage> { @override Widget build(BuildContext context) { var config = AppConfig.of(context); return Scaffold( appBar: AppBar( title: Text(config.appDisplayName), ), body: _buildBody(config.appDisplayName), ); } Widget _buildBody(String appName) { return Container( margin: EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), child: Column( children: <Widget>[ Text(appName), Text(DATE + getDateForDisplay()), Text(APP_DESCRIPTION), Image.asset('assets/dancing.png', width: 50.0, height: 50.0,), Image.asset('assets/1.png', width: 50.0, height: 50.0), ], ) ); } String getDateForDisplay() { DateTime now = DateTime.now(); var formatter = DateFormat('EEEE dd MMMM yyyy'); return formatter.format(now); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 [ . . . ] import 'package:flavorsexample/app_config.dart' ; class HomePage extends StatefulWidget { [ . . . ] } class _HomePageState extends State < HomePage > { @ override Widget build ( BuildContext context ) { var config = AppConfig . of ( context ) ; return Scaffold ( appBar : AppBar ( title : Text ( config . appDisplayName ) , ) , body : _buildBody ( config . appDisplayName ) , ) ; } Widget _buildBody ( String appName ) { return Container ( margin : EdgeInsets . symmetric ( vertical : 16.0 , horizontal : 16.0 ) , child : Column ( children : < Widget > [ Text ( appName ) , Text ( DATE + getDateForDisplay ( ) ) , Text ( APP_DESCRIPTION ) , Image . asset ( 'assets/dancing.png' , width : 50.0 , height : 50.0 , ) , Image . asset ( 'assets/1.png' , width : 50.0 , height : 50.0 ) , ] , ) ) ; } String getDateForDisplay ( ) { DateTime now = DateTime . now ( ) ; var formatter = DateFormat ( 'EEEE dd MMMM yyyy' ) ; return formatter . format ( now ) ; } }

Finally, to launch each config from the command line, you can simply type flutter run --flavor app1 -t lib/main_app1.dart and flutter run --flavor app2 -t lib/main_app2.dart

Or, from the IDE, you can edit each configuration as per screenshots below.

Note: renaming main.dart to main_common.dart means that we have to pass in a main file name when launching the app (as the default doesn’t exist), so we never accidentally launch the non configured app.

Gotcha 3: when launching from the IDE, I sometimes need to relaunch because even though I am launching “App2”, “App1” is being launched. But a hot restart usually fixes this. On a few rare occasions, I need to do flutter clean .



At this point, when you run each app, you will see the correct app title, but all the other strings and images are the same.

Back to code tutorial index



Customising some string resources based on flavor

In Android, we are used to overloading the xml resources so we can specify a different one for a different flavor. By this, I mean what we did above for the Android app_title string resource – we keep the name, we just give it a different value.

As everything in Flutter is Dart code, we need to use a Dart construct, that is an abstract class.

Let’s look at how it works in practice, by making the string APP_DESCRIPTION customizable per flavor.

We amend app_config.dart to add the abstract string class.

app_config.dart import 'package:flutter/material.dart'; class AppConfig extends InheritedWidget { AppConfig({this.appDisplayName,this.appInternalId, this.stringResource, Widget child}):super(child: child); final String appDisplayName; final int appInternalId; final StringResource stringResource; [...] } abstract class StringResource { String APP_DESCRIPTION; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import 'package:flutter/material.dart' ; class AppConfig extends InheritedWidget { AppConfig ( { this . appDisplayName , this . appInternalId , this . stringResource , Widget child } ) : super ( child : child ) ; final String appDisplayName ; final int appInternalId ; final StringResource stringResource ; [ . . . ] } abstract class StringResource { String APP_DESCRIPTION ; }

We remove APP_DESCRIPTION string from display_strings.dart and create 2 new files, in /lib/resource, named display_strings_app1.dart and display_strings_app2.dart, as below.

resource/display_strings_app1.dart import 'package:flavorsexample/app_config.dart'; class StringResourceApp1 implements StringResource { @override String APP_DESCRIPTION = "App Description for App 1"; } 1 2 3 4 5 6 7 import 'package:flavorsexample/app_config.dart' ; class StringResourceApp1 implements StringResource { @ override String APP_DESCRIPTION = "App Description for App 1" ; }

resource/display_strings_app2.dart import 'package:flavorsexample/app_config.dart'; class StringResourceApp2 implements StringResource { @override String APP_DESCRIPTION = "App Description for App 2"; } 1 2 3 4 5 6 7 import 'package:flavorsexample/app_config.dart' ; class StringResourceApp2 implements StringResource { @ override String APP_DESCRIPTION = "App Description for App 2" ; }

Note: by using an abstract class, we cannot forget to implement a string for a given flavor.

Then, we need to amend main_app1.dart and main_app2.dart to specify which string resource to use.

main_app1.dart [...] import 'package:flavorsexample/resource/display_strings_app1.dart'; void main() { var configuredApp = AppConfig( appDisplayName: "App 1", appInternalId: 1, stringResource: StringResourceApp1(), child: MyApp(), ); [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 [ . . . ] import 'package:flavorsexample/resource/display_strings_app1.dart' ; void main ( ) { var configuredApp = AppConfig ( appDisplayName : "App 1" , appInternalId : 1 , stringResource : StringResourceApp1 ( ) , child : MyApp ( ) , ) ; [ . . . ] }

main_app2.dart [...] import 'package:flavorsexample/resource/display_strings_app2.dart'; void main() { var configuredApp = AppConfig( appDisplayName: "App 2", appInternalId: 2, stringResource: StringResourceApp2(), child: MyApp(), ); [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 [ . . . ] import 'package:flavorsexample/resource/display_strings_app2.dart' ; void main ( ) { var configuredApp = AppConfig ( appDisplayName : "App 2" , appInternalId : 2 , stringResource : StringResourceApp2 ( ) , child : MyApp ( ) , ) ; [ . . . ] }

And we need to amend home_page.dart so it uses the string from the StringResource.

home_page.dart [...] class _HomePageState extends State<HomePage> { @override Widget build(BuildContext context) { var config = AppConfig.of(context); return Scaffold( appBar: AppBar( title: Text(config.appDisplayName), ), body: _buildBody(config.appDisplayName, config.stringResource), ); } Widget _buildBody(String appName, StringResource stringResource) { return Container( margin: EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), child: Column( children: <Widget>[ Text(appName), Text(DATE + getDateForDisplay()), Text(stringResource.APP_DESCRIPTION), Image.asset('assets/dancing.png', width: 50.0, height: 50.0,), Image.asset('assets/1.png', width: 50.0, height: 50.0), ], ) ); } [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 [ . . . ] class _HomePageState extends State < HomePage > { @ override Widget build ( BuildContext context ) { var config = AppConfig . of ( context ) ; return Scaffold ( appBar : AppBar ( title : Text ( config . appDisplayName ) , ) , body : _buildBody ( config . appDisplayName , config . stringResource ) , ) ; } Widget _buildBody ( String appName , StringResource stringResource ) { return Container ( margin : EdgeInsets . symmetric ( vertical : 16.0 , horizontal : 16.0 ) , child : Column ( children : < Widget > [ Text ( appName ) , Text ( DATE + getDateForDisplay ( ) ) , Text ( stringResource . APP_DESCRIPTION ) , Image . asset ( 'assets/dancing.png' , width : 50.0 , height : 50.0 , ) , Image . asset ( 'assets/1.png' , width : 50.0 , height : 50.0 ) , ] , ) ) ; } [ . . . ] }

Back to code tutorial index



Customising some assets based on flavor

As assets are specified in a folder, we can use a file structure to specify which asset to use, and then we can use the appInternalId (specified in AppConfig) to retrieve the correct one.

Firstly, we create folders 1 and 2 under assets. We then rename 1.png to icon.png and move it to the 1 folder. We then download and save 2.png in the 2 folder, and rename it to icon.png.

Secondly, we amend the home_page.dart to specify the correct icon based on the appInternalId.

[...] class _HomePageState extends State<HomePage> { @override Widget build(BuildContext context) { var config = AppConfig.of(context); return Scaffold( appBar: AppBar( title: Text(config.appDisplayName), ), body: _buildBody(config.appDisplayName, config.stringResource, config.appInternalId), ); } Widget _buildBody(String appName, StringResource stringResource, int appInternalId) { return Container( margin: EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), child: Column( children: <Widget>[ Text(appName), Text(DATE + getDateForDisplay()), Text(stringResource.APP_DESCRIPTION), Image.asset('assets/dancing.png', width: 50.0, height: 50.0,), Image.asset('assets/' + appInternalId.toString() + '/icon.png', width: 50.0, height: 50.0), ], ) ); } [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 [ . . . ] class _HomePageState extends State < HomePage > { @ override Widget build ( BuildContext context ) { var config = AppConfig . of ( context ) ; return Scaffold ( appBar : AppBar ( title : Text ( config . appDisplayName ) , ) , body : _buildBody ( config . appDisplayName , config . stringResource , config . appInternalId ) , ) ; } Widget _buildBody ( String appName , StringResource stringResource , int appInternalId ) { return Container ( margin : EdgeInsets . symmetric ( vertical : 16.0 , horizontal : 16.0 ) , child : Column ( children : < Widget > [ Text ( appName ) , Text ( DATE + getDateForDisplay ( ) ) , Text ( stringResource . APP_DESCRIPTION ) , Image . asset ( 'assets/dancing.png' , width : 50.0 , height : 50.0 , ) , Image . asset ( 'assets/' + appInternalId . toString ( ) + '/icon.png' , width : 50.0 , height : 50.0 ) , ] , ) ) ; } [ . . . ] }

Finally, we amend pubspec.yaml.

pubspec.yaml [...] assets: - assets/ - assets/1/ - assets/2/ 1 2 3 4 5 6 [ . . . ] assets : - assets / - assets / 1 / - assets / 2 /

Back to code tutorial index

Voila!

What next?

So, you’ve learned a way to use flavors in Flutter. There are a few different ways, so for a full understanding of your options, I recommend you checkout Flavoring Flutter.

Related