Flutter White Labeling: BuildVariants VS. Dependencies

Anton Rozdorozhniuk
Newsoft Official
Published in
17 min readFeb 13, 2024

Hello! My name is Anton and I’m Senior Flutter Developer at Newsoft with 10 years of mobile development under the belt.

Today, we’re going to talk about a few app labeling methods and compare them using Flutter. Before we start, I invite you to get to know the project’s code.

Let’s get the ball rolling!

White Labeling is a process of developing multi-purpose applications that are easy-to-adjust to different labels and companies. Those adjustments might be changing design elements, such as colors, logos, and other brand attributes.

Such an approach lets us save time and resources from developing separate applications for each client as the core functionalities remain the same while individual design elements are easy to adapt in accordance to client’s demands.

Why Flutter?

Flutter is a framework from Google developed for cross-platform development. It has been actively developing, expanding its community, and supporting all basic platforms for application development for more than five years.

It’s worth noting that Flutter has a great productivity, that is as close as possible to the native, has convenient development tools, such as hot reload, hot restart, and dev tools. Moreover, it’s cheaper, since you don’t need dedicated developers for each platform.

Flutter is a perfect match for branding and designing a white label project as it has convenient and easy-to-use configuration mechanisms that differ from usual flavors and schemes on Android and iOS. Such mechanism as Flavors is mentioned in the official Flutter documentation. Also, there is a guide on how to adjust it to your project needs, so it’s unlikely to have problems with it. But I tend to believe that there is a much easier way to do so, and today we’ll talk about it.

Task

Finally, we’ve reached the interesting part. Let’s imagine that we’ve come up with the most convenient shop interface and want to sell it to different brands. So, how to do that in a quick, efficient, and easy way?

Firstly, we figure out what application features can be customized for different brands. The main points that come to my mind are:

  • Application theme, colors, sizes, and text styles
  • Logos, icons, and other assets (we could’ve mentioned them in the previous part but I think we should talk about it separately)
  • Localization and supported languages
  • Features, the set of which can be changed according to the brand (some can be turned on, some — turned off)

Our task is to figure out how to set up a multi-brand application step-by-step following all points mentioned before. To pull it off, I suggest comparing two main approaches for the application labeling in Flutter: BuildTypes and Dependencies.

BuildVariant = ProductFlavors + BuildTypes

BuildVariants (known as SchemeConfiguration in iOS) allows you to develop variant builds for different app versions from a single project. Each BuildVariant is the separate version of the application. In our case, it seems to be the perfect way to create different configurations for each brand.

In Android, BuildVariant consists of a combination of ProductFlavor and BuildType. ProductFlavor, in its turn, describes the specific brand settings, such as unique application ID and public name, when BuildType — different types of builds for the app’s development, testing, and release.

Having all the info in mind, let’s get to the realization!

Setup

Using the official Flutter documentation, we create a demo project and describe main flavors for base white label projects. Flutter generously generates us a demo counter app, which we’ll label soon.

In the build.gradle file of the Android project, we create and describe our productsFlavors. Then, let’s add labels: base (basic counter that adds 1 when clicking the button), fib (Fibonacci counter that sums two preceding numbers when clicking the button), and doub (counter label that duplicates the number). We redefine the ID and application name for each.

flavorDimensions "label"
productFlavors {
base {
dimension "label"
applicationId "com.example.base"
resValue "string", "app_name", "Base"
}
fib {
dimension "label"
applicationId "com.example.fib"
resValue "string", "app_name", "Fib"
}
doub {
dimension "label"
applicationId "com.example.double"
resValue "string", "app_name", "Double"
}
}

Moreover, let’s add alternative BuildTypes that help us separate builds into release, debug, and profile by adding a suffix to the identifier:

buildTypes {
debug {
applicationIdSuffix ".debug"
debuggable true
}
profile {
applicationIdSuffix ".profile"
}
release {
signingConfig signingConfigs.debug
}
}

For iOS, we set up SchemeConfigurations in the same way. There are some differences but the logic remains the same. Additionally, we need to define separately the identifier and application name in the same way as it is described in the official Flutter documentation.

Config

Our next step to setting up is the creation of a configuration class, let’s name it AppConfig. Just by looking at the title it becomes clear that we have a separate configuration for each label, then we describe its interface in the basic sealed class:

sealed class AppConfig {
final BuildType _buildType;
const AppConfig(this._buildType);

BuildType get buildType => _buildType;
String get appName;
ThemeData get theme;
String get assetsPath;
List<Locale> get supportedLocales;
}

This configuration is created according to our needs for each label, we define a build type, title, theme, a path to assets, and a list of supported languages in it. Now let’s inherit from it three configs (BaseConfig, DoubConfig, FibConfig) for three types of application that we defined before.

class BaseConfig extends AppConfig {
BaseConfig(super.flavor);

@override
String get appName => 'Base';

@override
ThemeData get theme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
),
);

@override
String get assetsPath => 'assets/base';

@override
List<Locale> get supportedLocales => const <Locale>[
Locale('en', 'US'),
Locale('uk', 'US'),
];
}

class DoubConfig extends AppConfig {
DoubConfig(super.flavor);

@override
String get appName => 'Double';

@override
ThemeData get theme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.green,
),
);

@override
String get assetsPath => 'assets/double';

@override
List<Locale> get supportedLocales => const <Locale>[
Locale('uk', 'US'),
];
}

class FibConfig extends AppConfig {
FibConfig(super.flavor);

@override
String get appName => 'Fib';

@override
ThemeData get theme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.yellow,
),
);

@override
String get assetsPath => 'assets/fib';

@override
List<Locale> get supportedLocales => const <Locale>[
Locale('en', 'US'),
];
}

Those configs we’ll use later in the application to set up each label separately. Before doing that, we need to get flavor and buildType. To do so we create a MethodChannel (a mechanism that allows us to call code fragments from native platforms). For Android and iOS, we need to register a named channel that returns data from the chosen flavor.

AppDelegate.swift iOS:

import UIKit
import Flutter

let kChannel = "flavor"
let kMethodFlavor = "getFlavor"

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)

guard let controller = self.window.rootViewController as? FlutterViewController else { return true }
let flavorChannel = FlutterMethodChannel(name:kChannel, binaryMessenger: controller as! FlutterBinaryMessenger)
flavorChannel.setMethodCallHandler{ (call, result) in
if call.method == kMethodFlavor {
let flavor = Bundle.main.object(forInfoDictionaryKey: "Flavor") as! String
result(flavor);
}
}

return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

MainActivity.kt Android:

package com.example.flavors_app

import androidx.annotation.NonNull
import com.example.flavors_app.BuildConfig.FLAVOR
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

private const val kChannel = "flavor"
private const val kMethodFlavor = "getFlavor"
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine);

// Method channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, kChannel)
.setMethodCallHandler { call, result ->
if (call.method == kMethodFlavor) {
result.success(FLAVOR);
} else {
result.notImplemented()
}
}
}
}

Now, when we have corresponding implementations on both platforms, let’s go back to Flutter and add a method that calls MethodChannel and gets the needed flavor.
Why stop at this point, when our method can also return a BuildType? Of course, for simplicity, we also create two enums that describe basic types of builds and labels.

enum BuildType { debug, release, profile }

enum Label { base, doub, fib }

const String _methodChannelName = 'flavor';
const String _methodName = 'getFlavor';

Future<(Label, BuildType)> _getLabelAndBuildTypeFromPlatform() async {
Label label = Label.base;
BuildType buildType = BuildType.debug;
try {
String? flavorString = await (const MethodChannel(_methodChannelName))
.invokeMethod<String>(_methodName);
if (flavorString != null) {
if (flavorString == Label.fib.name) {
label = Label.fib;
} else if (flavorString == Label.doub.name) {
label = Label.doub;
}

if (kProfileMode) {
buildType = BuildType.profile;
} else if (kReleaseMode) {
buildType = BuildType.release;
}
}
} catch (e) {
log('Failed: ${e.toString()}', name: 'AppConfig');
log('FAILED TO LOAD FLAVOR', name: 'AppConfig');
}
return (label, buildType);
}

The only thing that’s left is to add a public method that returns a specific config in accordance to the received flavor and build type:

Future<AppConfig> getAppConfig() async {
final (label, buildType) = await _getLabelAndBuildTypeFromPlatform();

return switch (label) {
Label.base => BaseConfig(buildType),
Label.doub => DoubConfig(buildType),
Label.fib => FibConfig(buildType),
};
}

So, this chapter we finish with ready configurations on the Flutter side, registered channels for working with native parts of the code, and methods that return the needed config. Then, we only need to use the received config to set up the appearance and behavior of our applications.

Usage

To use the created configurations, firstly we need to load them at the start of the application before dwawing the content. To do so, we update the main() function and use the getAppConfig() method that we created before.

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

final appConfig = await getAppConfig();

runApp(MyApp(appConfig: appConfig));
}

We send a config to MyApp widget and use it to change the theme, application’s name, and supported languages (of course, we can utilize it to set up any similar settings).

class MyApp extends StatelessWidget {
final AppConfig appConfig;
const MyApp({super.key, required this.appConfig});

@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: appConfig.buildType == BuildType.debug,
title: appConfig.appName,
theme: appConfig.theme,
supportedLocales: appConfig.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: MyHomePage(appConfig: appConfig),
);
}
}

Finally, we get to modify our app to match the expectations depending on label configurations. For instance, we use a simple StatefulWidget with methods for each label. But in the real-life project it’d be more useful to put a config in the basis of the architecture.

Let’s create separate methods for each label and one general that returns a specific implementation relative to the config type:

 void _incrementCounter() {
setState(() {
_counter++;
});
}

void _doubleCounter() {
setState(() {
_counter *= 2;
});
}

void _fibCounter() {
setState(() {
final prev = _counter;
_counter = _prevCounter + _counter;
_prevCounter = prev;
});
}

void _count() => switch (widget.appConfig) {
DoubConfig() => _doubleCounter(),
FibConfig() => _fibCounter(),
BaseConfig() => _incrementCounter(),
};

In the same way, let’s implement getters for text above the counter and the path to the image that will be displayed on the main screen for each brand.

String _getTitle(BuildContext context) => switch (widget.appConfig) {
DoubConfig() => AppLocalizations.of(context)!.title_double,
FibConfig() => AppLocalizations.of(context)!.title_fib,
BaseConfig() => AppLocalizations.of(context)!.title_base,
};

String _imgPath() => switch (widget.appConfig) {
FibConfig() => '${widget.appConfig.assetsPath}/img.webp',
_ => '${widget.appConfig.assetsPath}/img.png',
};

After all updates mentioned above, our build method in the main app widget will look like this:

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text('${widget.appConfig.appName} Counter App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
height: 300.0,
child: Image.asset(_imgPath()),
),
const Spacer(),
Text(
_getTitle(context),
textAlign: TextAlign.center,
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const Spacer(flex: 2),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _count,
child: const Icon(Icons.add),
),
);
}

Consequently, we have three different applications:

As we can see on screenshots, we’ve managed to redefine translations, colors, themes, images, and functionality for each brand but unfortunately (in case of functionality) we can’t show it here.

It’s only a part of the labeling process as at the assembly stage we’ll need to change the icon for each brand. Additionally, we had to duplicate translations for each variant and create a separate branch for selecting a translation depending on the brand. All these nuances kind of limit our possibilities in the future and add some extra work.

Review

In my opinion, it’s a simple and clear approach to labeling mobile applications. Also, as a big plus, it’s not necessary to use complex structures or inheritance for overriding functionality (although, from the other side it is a minus as it limits the possibility for further modifications).

To sum up, this config is fast to set up and quite straightforward, therefore, not flexible enough. That’s why, it can be used either for the project with a few brands or on the first stages of the project, for example, MVP stage.

For other instances, especially for projects with multiple brands, we need something more complex with bigger potential for expanding. And, luckily for us, there’s a Dependencies approach, and we’ll look at it right now.

Dependencies labeling

In this case we mean dependencies on the module level not inside the project. So, the idea is the following: we need to create a basic module and inherit all labels from it. That way we can redefine themes, any resources, translations, and some widgets. Moreover, it all perfectly combines with buildVariants and config from the previous chapter. All those factors give us wider possibilities for settings and good structure.

No reasons to hesitate, let’s get started!

Setup

In the new directory, we create new Flutter project and name it base. Right after that, we add a flavor module that has a folder in it with assets for a particular brand, redefined configs, and other files inside the lib folder. Of course, we add pubspec.yaml there as well to set up project dependencies and l1on.yaml to configure translation generation.

In flavor/pubspec.yaml, we add a dependency to base module and other more useful settings to the Flutter project. Straight away, I can highlight the advantage of such an approach as we can control separately each version for each brand.

name: flavor
description: "Flavor of the base app"

publish_to: 'none'

version: 1.0.0+1

environment:
sdk: '>=3.2.4 <4.0.0'

dependencies:
base:
path: ../../base

Now we need to add a reverse dependency to the flavor module in the middle of base/pubspec.yaml along with adding flutter_localizations and intl to support different languages, as well as a reference to the assets that will be in the flavor module:

dependencies:
flutter:
sdk: flutter

flavor:
path: flavor

flutter_localizations:
sdk: flutter
intl: any

flutter:

uses-material-design: true
generate: true
assets:
- flavor/assets/

In the future all libraries that will be used in the base projects or derived labels should be added to base or local pubspec.yaml files.

Then, we modify main.dart and divide MyApp widget into two separate entities with the content of HomePage application and the configuration of MaterialApp. It should be done to keep the ability to redefine the widget with the application content if needed later. Also, we add configs of assets, translations, and themes to the config folder.

All those configs are abstract classes with basic settings that are easy-to-redefine for each separate label in the future. Apart from them, I also added use_case folder with the class BaseCount that does the math because we create the same counters as we did in the previous example:

class BaseCount {
int call(int counter) => counter + 1;
}

But that’s not all, after we created all basic classes and configurations, we need to redefine them in the flavor module. Owing to the redefining of the flavor module, we can create new brands with the link to the basic one.

Config

So, at the beginning, we need to create White Label (in our project it’s base flavor) and inherit all config-files and use-cases without changes. Basically, all data remains the same that we have in the basic project. Also, let’s generate translations for English and Ukrainian languages from app_en.arb and app_uk.arb files, using the official documentation.

We get the next files structure. Also, you might have already noticed that there is an action.button.dart, which I hadn’t mentioned before. It’s a widget that we’ll redefine for our purposes in different labels. It’s used in home_page.dart and looks like this:

class ActionButton extends StatelessWidget {
final int counter;
final void Function(int) onCounterUpdated;
const ActionButton({
super.key,
required this.counter,
required this.onCounterUpdated,
});

@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () {
final value = FlavorCount().call(counter);
onCounterUpdated(value);
},
child: const Icon(Icons.add),
);
}
}

In this widget, we utilize the FlavorCount use case inherited from the BaseCount use case and also can be redefined from other labels. ActionButton widget and other config-classes are used in HomePage class like this:

@override
Widget build(BuildContext context) {
final localizations = FlavorLocalizations();
final l10n = localizations.getAll(context);
final assets = FlavorAssets();

return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text('${localizations.appName} Counter App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
height: 300.0,
child: Image.asset(assets.logo),
),
const Spacer(),
Text(
l10n.title,
textAlign: TextAlign.center,
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const Spacer(flex: 2),
],
),
),
floatingActionButton: ActionButton(
counter: _counter,
onCounterUpdated: (value) => setState(() => _counter = value),
),
);
}

This content’s widget looks almost the same as in the previous example from BuildVariants. It has some minor differences only when using ActionButton, FlavorLocalizations, and FlavorAssetts inherited from the flavor module.

Here’s the result, our base application:

It looks exactly like the one from the previous example, and it’s a win!

Usage

We’re finally over with the preparation and can get to creating the rest of the brands, particularly fib and double. To set all of them up we need to do exactly the same, so we’ll look at only one of them to get the idea.

Let’s create a new project in the same folder, where we created the base. Name it fib, and we get the next structure:

Basically, we can copy all the structure from the base project into our new label, as the majority of the files are almost identical with minor differences. For instance, in pubspec.yaml we give a link to the base module and redefine a link in the flavor module. The important part here is to redefine it, as we’ll need to replace our base module with a local one. It looks like this:

dependencies:
base:
path: ../base

dependency_overrides:
flavor:
path: flavor

In the lib folder for the fib module, we need to redefine the main function, which we call from the base project as it is a main and base enter point into the application. This way main.dart will look like this:

import 'package:base/main.dart' as base;
void main() => base.main();

The only thing that’s left is to modify the local flavor module. It’s very simple, to do so we need to inherit from all existing base functions and redefine those parameters that differ from the fib project. Particularly, a path to the main screen in localizations, the main color in the theme.

The main thing here is to pay attention to the fact that all classes are named the same in each label in the flavor module, this is necessary to ensure that all classes can be used interchangeably when redefining dependencies.

class FlavorAssets extends BaseAssets {
@override
String get logo => 'flavor/assets/img.webp';
}
class FlavorLocalizations extends BaseLocalizations {
@override
List<Locale> get supportedLocales => const <Locale>[Locale('en')];

@override
String get appName => 'Fib';
}
class FlavorTheme extends BaseTheme {
@override
Color get seed => Colors.yellow;
}

When all configurations are done, we get to the final app functionality. Here everything is clear as we have created a use case that counts the next number. So, it’s enough to inherit it and redefine in a way that it would count the next Fibonacci:

class FlavorCount extends BaseCount {
final int _previousCounter;
FlavorCount(this._previousCounter);

@override
int call(int counter) => _previousCounter + counter;
}

Since in the new use case we need the previous number of the counter, we redefine the ActionButton widget as well. All we need is to modify it in the StatefulWidget, store the counter value locally in the state, and pass it to the use case for the correct counts:

class ActionButton extends StatefulWidget {
final int counter;
final void Function(int) onCounterUpdated;
const ActionButton({
super.key,
required this.counter,
required this.onCounterUpdated,
});

@override
State<ActionButton> createState() => _ActionButtonState();
}

class _ActionButtonState extends State<ActionButton> {
int _previousCounter = 1;

@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () {
final value = FlavorCount(_previousCounter).call(widget.counter);
setState(() => _previousCounter = widget.counter);
widget.onCounterUpdated(value);
},
child: const Icon(Icons.add),
);
}
}

Hatts off, we’ve managed to build a set of three projects, where each describes the separate brand. Let’s have a look at it!

Review

Of course, this is not the whole labeling but only the tip of the iceberg. The potential of this approach is much bigger than the previous one, because here we already have the opportunity to change the icon for each project without applying resources during the build process.

Also, we have the opportunity to customize the native part for each brand completely independently. Although, it’s a minus on the other hand, as it generates a large amount of copy paste when we need to change something identical for different brands, and this will happen constantly, when changing the basic project.

BuildVariants VS. Dependencies

The difference in simplicity, flexibility, and amount of code written is clearly visible in the two labeling options we discussed earlier. Let’s briefly look at the main points that need to change for different brands and compare them:

Setup and preparation

In fact, both methods are equally simple, although the BuildVariant approach clearly requires knowledge of native development. Therefore, in my opinion, the Dependencies approach is a win-win, as it is suitable for any Flutter Developer. In addition, it can also be successfully combined with BuildVariant for more extensive settings.

Assets

It’s more interesting here, but here’s a spoiler, in my opinion, once again, the Dependencies method wins. Here’s why: firstly, it’s much easier to change the app icon when we have a separate project for each label; secondly, all other assets are not duplicated and do not need to be replaced every time a new project is assembled. Therefore, any issues with assets are solved much easier than with build options.

Localization

There isn’t much of a difference here as I would expect different brands to have the same translations, maybe just with different supported languages. And it’s easy to identify different supported languages regardless of the labeling approach. The only problem that can occur is overloading the project with unused language files, which can cause the resulting build to take up a little more space.

Features and branded logic

Well, I didn’t cover this topic deep enough in this article but I believe that in each labeling method you can implement imitation and/or something like a factory that returns the desired implementation for each brand. So, there is no big difference here but it is necessary to write more and redefine the code in the Dependencies approach. And this is both a plus and a minus at the same time, since there is more code but the structure is much more transparent.

Conclusions

It is immediately clear that everything here is not as clear as it might seem at first glance. But, still, I would recommend Dependencies. Since this method is easier to understand, does not require additional knowledge of the native part, has a clear division by brands into separate projects, does not require additional settings of the build process, resource replacement and similar things, is much easier to expand and, finally, can be combined with native build options. Despite the significantly larger amount of code and copy paste, this is definitely a win-win for any number of brands in your project.
I would also like to remind you of the links to all the projects described above for those who want to learn more.

--

--