DEV Community

Cover image for Unlock Seamless App Environments with Flutter Flavors: A Must-Know Guide!
Vincentius Westley for Tentang Anak Tech Team

Posted on • Updated on

Unlock Seamless App Environments with Flutter Flavors: A Must-Know Guide!

In an era of so much rapid development in this field of technology, efficiency and speed in the development of a mobile application are very important. In this article, I will discuss the flavor of Flutter that makes mobile application development more simple and seamless, as well as the solution to the problem of efficiency and speed in the development of mobile applications that we are facing today.


🔍 Navigating the Challenges

Imagine you, as a mobile developer, are asked to create a complex application. You are asked for your application to have several versions for several target users. For example, in an application that already exists in the Play Store and App Store, you are required to create three applications: applications that are used for global users, applications used for testers, and the last application used for developers.

If you create the three applications manually, imagine how tricky and time-consuming the development process is. You need to customize the UI, change the source of your data, and build it one by one for every required application. In some cases, you may even need more than three of the same applications in different environments.

Here's the flavor role that can help simplify and shorten the problem that was discussed earlier: With Flutter Flavor, you just need to create one source code; all you need to do is just configure whatever environment you're going to make and whatever content or assets that you'll make dynamic on each application in a different environment. So implementing Flutter Flavor will not only speed up and simplify the development process, but it will also reduce the risk of errors that will occur if you separate ingredients manually.


🔧 How it works?

In this current case, we will try to differentiate the application name, application logo, and base URL API for every application variant. This configuration will later be encapsulated in every flavor file you create, which will then be dynamically accessible to your source code in real time.
To illustrate how Flutter Flavor works, first we need to name our three applications as more clear and understandable environments by engineers; usually, these three environments will be separated into "production, staging, and development.”

  • Production
    This environment will be used for the entire final user that will be using your application, which will be on the Play Store and App Store.

  • Staging
    This environment will be used for application testing that also reflects production data without interfering with original production data.

  • Development
    This environment will be used for developers, where developers develop applications and also experiment.

The way it works is that you need to define the three environments described; each environment needs a configuration file that contains contents that you’re going to create dynamically (in this case, the name of the application, the application's logo, and the API base URL). Then you have to do the configuration on each native platform (Android and iOS), so that the native platform that you build will later know the three platforms that you have created.

After making the configurations on the native platform, you also need to configure the IDE that you use (Android Studio or Visual Studio Code) so the IDE knows every environment you’ve created, which later will make it easier for you when running or debugging your applications (you just need to choose the environment that you’re going to run).


💻 Implementation

Let's demonstrate the implementation of flavors in a Flutter project using the example outlined above:

Step 1: Create Flavor Configuration Files

flavor_config.dart

import 'package:flutter/material.dart';

// Define an enum for every environment you want to create.
enum Flavor { prod, staging, dev }

// Add more values as needed.
class FlavorValues {
  final String apiBaseUrl;
  final String appIcon;
  final String appName;

  FlavorValues({
    required this.apiBaseUrl,
    required this.appIcon,
    required this.appName,
  });
}

class FlavorConfig {
  final Flavor flavor;
  final String name;
  final Color color;
  final FlavorValues values;
  static late FlavorConfig _instance;

  factory FlavorConfig({
    required Flavor flavor,
    // Color is an optional value that will be used for the flavor banner.
    // Flavor banner is one way to identify the environment you used.
    // You can remove this line of code if you don't need it.
    Color color = Colors.blue,
    required FlavorValues values,
  }) {
    _instance = FlavorConfig._internal(flavor, flavor.name, color, values);
    return _instance;
  }

  FlavorConfig._internal(this.flavor, this.name, this.color, this.values);

  static FlavorConfig get instance {
    return _instance;
  }

  // Method to check your current environment in your realtime code
  static bool isDevelopment() => _instance.flavor == Flavor.dev;

  static bool isStaging() => _instance.flavor == Flavor.staging;

  static bool isProduction() => _instance.flavor == Flavor.prod;
Enter fullscreen mode Exit fullscreen mode

Step 1.5 (Optional): Create Flavor Banner Configuration Files

flavor_banner.dart

import 'dart:io';

import 'package:flutter/material.dart';

import 'flavor_config.dart';

const double _kHeightBanner = 12.0;
const double _kOffsetBanner = 40.0;
final double _kSizeTriangle =
    _kOffsetBanner + 0.707 * _kHeightBanner * (Platform.isIOS ? 2 : 3);

class FlavorBanner extends StatelessWidget {
  final Widget child;
  final BannerConfig? bannerConfig;

  const FlavorBanner({
    Key? key,
    required this.child,
    this.bannerConfig,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // You need to make sure your flavor banner doesn't show up in production.
    // If you want to show the banner in production, delete this line below.
    if (FlavorConfig.isProduction()) return child;
    BannerConfig config = bannerConfig ?? _getDefaultBanner();

    return Stack(
      children: <Widget>[
        child,
        _buildBanner(
          context: context,
          bannerConfig: config,
        ),
      ],
    );
  }

  BannerConfig _getDefaultBanner() {
    return BannerConfig(
      bannerName: FlavorConfig.instance.name,
      bannerColor: FlavorConfig.instance.color,
    );
  }

  // Creating banner allies so the banner will not be too long and overlap the UI.
  String getBannerAlias(String bannerName) {
    switch (bannerName) {
      case "production":
        return "PROD";

      case "stage":
        return "STAGE";

      case "dev":
        return "DEV";

      default:
        return "";
    }
  }

  Widget _buildBanner({
    required BuildContext context,
    required BannerConfig bannerConfig,
    BannerLocation location = BannerLocation.topStart,
  }) {
    return CustomPaint(
      painter: BannerPainter(
        message: getBannerAlias(bannerConfig.bannerName),
        textDirection: TextDirection.ltr,
        layoutDirection: TextDirection.ltr,
        location: location,
        color: bannerConfig.bannerColor,
      ),
      child: ClipPath(
        clipper: _TriangleClipper(location: location),
        child: Container(
          width: _kSizeTriangle,
          height: _kSizeTriangle,
          color: Colors.transparent,
        ),
      ),
    );
  }
}

class BannerConfig {
  final String bannerName;
  final Color bannerColor;

  BannerConfig({required this.bannerName, required this.bannerColor});
}

class _TriangleClipper extends CustomClipper<Path> {
  _TriangleClipper({
    required this.location,
  });

  BannerLocation location;

  @override
  Path getClip(Size size) {
    final path = Path();
    if (location == BannerLocation.topStart) {
      path.lineTo(size.width, 0);
      path.lineTo(size.width, Platform.isIOS ? 0 : size.height / 4);
      path.lineTo(0, size.height);
      path.lineTo(0, 0);
    }
    if (location == BannerLocation.topEnd) {
      path.lineTo(size.width, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, Platform.isIOS ? 0 : size.height / 4);
      path.lineTo(0, 0);
    }
    if (location == BannerLocation.bottomStart) {
      path.lineTo(0, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
      path.lineTo(0, 0);
    }
    if (location == BannerLocation.bottomEnd) {
      path.lineTo(size.width, 0);
      path.lineTo(size.width, size.height);
      path.lineTo(0, size.height);
      path.lineTo(size.width, 0);
    }
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a mains file for every environment you’ve created

main_prod.dart

import 'main.dart';
import 'utils/flavor_config.dart';

void main() async {
  FlavorConfig(
    flavor: Flavor.prod,
    values: FlavorValues(
      apiBaseUrl: "https://apibaseurl-prod.com",
      appIcon: "assets/images/app_icon_prod.png",
      appName: "Application Production",
    ),
  );

  // Call your main method in here
  initializeApp();
}
Enter fullscreen mode Exit fullscreen mode

main_staging.dart

import 'utils/flavor_config.dart';
import 'main.dart';

void main() async {
  FlavorConfig(
    flavor: Flavor.staging,
    values: FlavorValues(
      apiBaseUrl: "https://apibaseurl-staging.com",
      appIcon: "assets/images/app_icon_staging.png",
      appName: "Application Staging",
    ),
  );

  // Call your main method in here
  initializeApp();
}
Enter fullscreen mode Exit fullscreen mode

main_dev.dart

import 'utils/flavor_config.dart';
import 'main.dart';

void main() async {
  FlavorConfig(
    flavor: Flavor.dev,
    values: FlavorValues(
      apiBaseUrl: "https://apibaseurl-dev.com",
      appIcon: "assets/images/app_icon_dev.png",
      appName: "Application Dev",
    ),
  );

  // Call your main method in here
  initializeApp();
}
Enter fullscreen mode Exit fullscreen mode

and your original main.dart will looks like this

import 'package:flutter/material.dart';
import 'package:flutter_flavor_implementation_example/presentations/home_page.dart';
import 'package:flutter_flavor_implementation_example/utils/flavor_banner.dart';
import 'package:flutter_flavor_implementation_example/utils/flavor_config.dart';

void initializeApp() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: FlavorConfig.instance.values.appName,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      builder: (_, child) {
        return FlavorBanner(
          child: child ?? const SizedBox(),
        );
      },
      home: HomePage(title: FlavorConfig.instance.values.appName),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

and your home_page.dart will looks like this

import 'package:flutter/material.dart';
import 'package:flutter_flavor_implementation_example/utils/flavor_config.dart';

class HomePage extends StatefulWidget {
  final String title;

  const HomePage({super.key, required this.title});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You currently running the app using ${FlavorConfig.instance.name} env',
            ),
            Text(
              "\nYour current environment base url is \n${FlavorConfig.instance.values.apiBaseUrl}",
              textAlign: TextAlign.center,
            ),
          ],
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Make sure, after following all the steps above, you have all the required files and your project structure looks like this:
Project Structure


Step 3: Setting up flavor in iOS by configuring the schemes
In this step, you will configure iOS bundle schemes and bundle identifier

  1. Open your project in Xcode (ios/Runner.xcworkspace).
  2. In your Runner Project, add -production suffix in the current configuration. To rename the configuration, simply double-click the configuration and make sure it looks like this: Runner Configuration
  3. Now, you need to duplicate every configuration for every flavor you have by selecting the Add (+) icon and duplicating the configuration. Make sure you duplicate the correct configuration. In order to make Debug-staging you need to duplicate it from Debug-production not Release-production or Profile-production and it applies to all configurations. Duplicate Configuration After you duplicate all the configuration for every flavor, make sure your configuration looks like this: Final Configuration
  4. Now in your Runner Targets, go to Build Settings, search for Product Bundle Identifier, and edit your bundle identifier by adding .staging and .dev suffix to your bundle identifier. We will exclude production because the production environment doesn't need the suffix. Bundle Identifier
  5. After that, if you wanted to make your application name different between every flavor, you needed to go to Runner Targets, then go to Build Settings, and find Product Name. Change it to your desired application name. Product Name
  6. After you change your Product Name, to apply the changes to your app name, go to your Info.plist. (Runner > Runner > Info). Update the Bundle Display Name value to $(PRODUCT_NAME). Bundle Display Name
  7. Now, you need to rename your current scheme to production and also create two more schemes (staging and dev). You can rename your current scheme by opening Product > Scheme > Manage Schemes from the menu Manage Schemes And then duplicate your Runner scheme. Duplicate Scheme Make sure you name it production with lowercase because it is case-sensitive. Production Scheme After you duplicate your current scheme, you can safely delete it by using the Remove (-) icon in the left-bottom Remove Runner Scheme
  8. Now, for the staging and dev you can create a new scheme by selecting Product > Scheme > New Scheme from the menu. Make sure it targets the Runner, and is also named with lowercase. Staging Scheme Dev Scheme
  9. After that, you need to manage your staging and dev schemes so they target the correct configuration. To do that, you need to go to Product > Scheme > Manage Schemes from the menu. Manage Schemes Select the staging scheme, and select the Build Configuration to correct the configuration you created. For example, in Run Menu, you need to change the Build Configuration to Debug-staging, and then in the Profile Menu, you need to change it to Profile-staging and apply it to all the menus. Staging Configuration Do the same with the dev scheme. Dev Configuration

Step 4: Setting up flavor in Android by configuring the bundle

  1. Inside your Flutter project, navigate to android/app/build.gradle.
  2. Create a flavorDimension to group your added product flavors. Gradle doesn’t combine product flavors that share the same dimension.
  3. Add a productFlavors object with the desired flavors along with values for dimension, resValue, and applicationId or applicationIdSuffix.
defaultConfig {
  ...
}

buildTypes {
  release {
    ...
  }
}

// Add these lines of code.
flavorDimensions "app"
productFlavors {
    production {
        dimension "app"
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        resValue "string", "app_name", "Flavor"
    }
    staging {
        applicationIdSuffix ".staging"
        dimension "app"
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        resValue "string", "app_name", "Flavor Staging"
    }
    dev {
        applicationIdSuffix ".dev"
        dimension "app"
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        resValue "string", "app_name", "Flavor Dev"
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, in your AndroidManifest.xml update your android:label to @string/app_name

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.vincwestley.flutter_flavor_implementation_example">
   <application
        android:label="@string/app_name"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

Step 5: Setting up your IDE launch configurations

5.1: Android Studio with UI

  1. In your Android Studio Menu Bar, select Edit Configuration Android Studio Edit Configuration
  2. Update your main.dart configuration to main_prod.dart, also change the Dart entrypoint to your main_prod.dart file location, and change the Build flavor value to production. Production Configuration
  3. For staging, duplicate your main_prod.dart configuration. Duplicate Configuration
  4. Update all the configuration to target staging values: name, entrypoint, and build flavor. Staging Configuration
  5. Do the same for the dev configuration. Dev Configuration

5.2: Android Studio Manually
In the root directory of your project, add a folder called .run. Inside the .run folder, create a file named main-prod.dart.run.xml, main-staging.dart.run.xml, and main-dev.dart.run.xml.

main-prod.dart.run.xml

<component name="ProjectRunConfigurationManager">
  <configuration default="false" name="main-prod.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
    <option name="buildFlavor" value="production" />
    <option name="filePath" value="$PROJECT_DIR$/lib/main_prod.dart" />
    <method v="2" />
  </configuration>
</component>
Enter fullscreen mode Exit fullscreen mode

main-staging.dart.run.xml

<component name="ProjectRunConfigurationManager">
  <configuration default="false" name="main-staging.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
    <option name="buildFlavor" value="staging" />
    <option name="filePath" value="$PROJECT_DIR$/lib/main_staging.dart" />
    <method v="2" />
  </configuration>
</component>
Enter fullscreen mode Exit fullscreen mode

main-dev.dart.run.xml

<component name="ProjectRunConfigurationManager">
  <configuration default="false" name="main-dev.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
    <option name="buildFlavor" value="dev" />
    <option name="filePath" value="$PROJECT_DIR$/lib/main_dev.dart" />
    <method v="2" />
  </configuration>
</component>
Enter fullscreen mode Exit fullscreen mode

5.3 Visual Studio Code
In the root directory of your project, add a folder called .vscode, Inside the .vscode folder, create a file named launch.json. In the launch.json file, add a configuration object for each flavor. Each configuration has a name, request, type, program, and args key.

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "prod",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_prod.dart",
      "args": ["--flavor", "production", "--target", "lib/main_prod.dart" ]
    },
    {
      "name": "staging",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_staging.dart",
      "args": ["--flavor", "staging", "--target", "lib/main_staging.dart" ]
    },
    {
      "name": "dev",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_dev.dart",
      "args": ["--flavor", "dev", "--target", "lib/main_dev.dart" ]
    }
  ],
  "compounds": []
}
Enter fullscreen mode Exit fullscreen mode

By following the steps above, you’ve implemented a Flutter flavor that will make it easier for us to do application management with multiple dynamic data points. It will also make it easier for us to do maintenance and provide flexibility as well as efficiency in the development process.


🔥 App Demo

You can check out the full source code here: Github

Demo 1

You will have three different apps for the singular code with different data and configurations.
Demo 2


⭐ Conclusion

Flutter flavor makes mobile application development more simple and seamless, as well as the solution to the problem of efficiency and speed in the complex development of mobile applications that need to implement multiple environments. Implementing Flutter Flavor will not only speed up and simplify the development process, but it will also reduce the risk of errors that will occur if you separate ingredients manually.


📄 References


Top comments (0)