Flavors & Environment #

In the professional mobile app development cycle, you always need more than one working environment (environment). Ideally, there are at least three separate working environments:

  1. Development (Dev): The environment used by developers to experiment, write new features, aggressively enable debug logs, and test code against local databases or development servers.
  2. Staging (Stg) / QA: The environment that precisely replicates production conditions. Used by Quality Assurance teams to test app functionality, test third-party external API integrations, and do performance testing before code is pushed to the release stage.
  3. Production (Prod): The final release environment downloaded and used by real users on the App Store or Google Play Store. Security, API key confidentiality, and performance efficiency are highly prioritized in this environment.

This environment separation guarantees that development or testing activities won’t contaminate transaction data in production databases, and ensures production secret API keys aren’t accidentally exposed.


Environment Separation Architecture in Mobile Apps #

In Flutter, working environment separation is done at two different architecture levels:

  • Dart-Level Configuration: Manages variables only relevant to your Dart code, like API gateway URL endpoints, network connection timeout durations, or boolean flags for enabling debug logs and mock services. You leverage the --dart-define-from-file parameter to inject these variables at compilation time.
  • Native-Level Flavors: Manages configurations embedded in the target operating system. This includes App Name differences (e.g., [DEV] Our Store vs Our Store), App Package Identities (Application IDs on Android and Bundle IDs on iOS), different launcher icons so testers aren’t confused, and native SDK integration files (like Firebase’s google-services.json).

By configuring native flavors, you can install the Dev, Staging, and Prod app versions side-by-side on the same physical device at once.

Here’s a flow diagram of Flutter app compilation based on the combination of native flavors and Dart variable injection:

flowchart TD
    BuildTrigger(["Start Compilation (flutter run / build)"]) --> SelectConfig{"Choose Environment & Target"}
    
    SelectConfig -- "--flavor development" --> AndroidDev["Android: Product Flavor 'development'<br/>Application ID: com.app.dev<br/>Icon: icon_dev.png"]
    SelectConfig -- "--flavor production" --> AndroidProd["Android: Product Flavor 'production'<br/>Application ID: com.app<br/>Icon: icon_prod.png"]
    
    SelectConfig -- "--dart-define-from-file" --> DartInject["Dart Compiler:<br/>Reads the JSON Config<br/>Injects constants via VM"]

    AndroidDev --> CompileNative["Native Compiler (Gradle / Xcode)"]
    AndroidProd --> CompileNative
    DartInject --> CompileNative
    
    CompileNative --> FinalOutput["Final Binary (APK/AAB/IPA)<br/>API Endpoint: dev/prod URL<br/>App Name: [DEV] Name / Name<br/>Can be installed side-by-side"]

Approach 1: Dart-Level Configuration via –dart-define-from-file #

Modern Flutter provides the --dart-define-from-file parameter which lets you inject a collection of environment variables based on external JSON files at compile time. This is much cleaner and easier to manage than writing dozens of --dart-define parameters manually in the command line terminal.

1. Creating JSON Configuration Files #

Create a new directory named .dart_define/ at your project root (make sure this directory is added to .gitignore). Create three separate configuration files:

// .dart_define/development.json
{
  "APP_ENV": "development",
  "API_URL": "https://api-dev.unisbadri.com/v1",
  "API_KEY": "key_dev_abcsystem123",
  "ENABLE_LOGS": "true",
  "TIMEOUT_DETIK": "10"
}
// .dart_define/staging.json
{
  "APP_ENV": "staging",
  "API_URL": "https://api-stg.unisbadri.com/v1",
  "API_KEY": "key_stg_xyztesting456",
  "ENABLE_LOGS": "true",
  "TIMEOUT_DETIK": "15"
}
// .dart_define/production.json
{
  "APP_ENV": "production",
  "API_URL": "https://api.unisbadri.com/v1",
  "API_KEY": "key_prod_securedsystem789",
  "ENABLE_LOGS": "false",
  "TIMEOUT_DETIK": "30"
}

2. Implementing a Type-Safe AppConfig Class #

Inside the Dart code, you read those variables using the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment methods. You centralize access to these variables inside a unified configuration class.

// lib/core/config/app_config.dart
enum EnvironmentType { development, staging, production }

class AppConfig {
  // 1. Read raw data from the compile-time environment
  static const String _env = String.fromEnvironment(
    'APP_ENV',
    defaultValue: 'development',
  );

  static const String apiUrl = String.fromEnvironment(
    'API_URL',
    defaultValue: 'https://api-dev.unisbadri.com/v1',
  );

  static const String apiKey = String.fromEnvironment(
    'API_KEY',
    defaultValue: '',
  );

  static const bool enableLogs = bool.fromEnvironment(
    'ENABLE_LOGS',
    defaultValue: true,
  );

  static const int timeoutSeconds = int.fromEnvironment(
    'TIMEOUT_DETIK',
    defaultValue: 10,
  );

  // 2. Map the string into a safe enum type
  static EnvironmentType get environment {
    switch (_env) {
      case 'staging':
        return EnvironmentType.staging;
      case 'production':
        return EnvironmentType.production;
      default:
        return EnvironmentType.development;
    }
  }

  static bool get isDevelopment => environment == EnvironmentType.development;
  static bool get isStaging => environment == EnvironmentType.staging;
  static bool get isProduction => environment == EnvironmentType.production;

  /// Example dynamic parameter whose value is determined based on the environment
  static Duration get connectionTimeout => Duration(seconds: timeoutSeconds);
}

To run the app using this configuration in the terminal, use the commands:

# Run in the development environment
flutter run --dart-define-from-file=.dart_define/development.json

# Build the production release binary
flutter build apk --release --dart-define-from-file=.dart_define/production.json

Approach 2: Native-Level Configuration via Product Flavors & Schemes #

If you want to change the app package identity (application ID) and app name on phone screens so Dev, Staging, and Prod apps can be installed side-by-side, you must do configuration at the native platform level.

1. Android Product Flavors Setup #

Open the android/app/build.gradle file (not the root build.gradle), then add the flavorDimensions and productFlavors configuration blocks inside the android { ... } block:

android {
    ...
    defaultConfig {
        applicationId "com.unisbadri.tokokita"
        minSdkVersion flutterMinSdkVersion
        targetSdkVersion flutterTargetSdkVersion
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }

    // 1. Determine the release dimension
    flavorDimensions "default"

    // 2. Define the configuration for each flavor
    productFlavors {
        development {
            dimension "default"
            applicationIdSuffix ".dev" // Produces com.unisbadri.tokokita.dev
            resValue "string", "app_name", "[DEV] Our Store"
        }
        staging {
            dimension "default"
            applicationIdSuffix ".staging" // Produces com.unisbadri.tokokita.staging
            resValue "string", "app_name", "[STG] Our Store"
        }
        production {
            dimension "default"
            // Uses the default applicationId without a suffix (com.unisbadri.tokokita)
            resValue "string", "app_name", "Our Store"
        }
    }
}

Open the android/app/src/main/AndroidManifest.xml file, find the <application> tag section and change the android:label attribute value to read the dynamic string value from our flavor:

<application
    android:label="@string/app_name"
    android:icon="@mipmap/ic_launcher"
    ...>

2. iOS Schemes & Configurations Setup #

On iOS, you duplicate Xcode’s built-in release configurations (Debug, Release, Profile) to be flavor-oriented:

  1. Open the iOS project in Xcode (open ios/Runner.xcworkspace).
  2. Select the Runner project in the left panel $\rightarrow$ Info tab $\rightarrow$ find the Configurations section.
  3. Duplicate the existing configurations by clicking the + icon:
    • Duplicate Debug into: Debug-development, Debug-staging, Debug-production.
    • Duplicate Release into: Release-development, Release-staging, Release-production.
    • Duplicate Profile into: Profile-development, Profile-staging, Profile-production.
  4. Select Build Settings $\rightarrow$ find Product Bundle Identifier. Adjust the package ID for each configuration:
    • For all *-development configurations, fill: com.unisbadri.tokokita.dev
    • For all *-staging configurations, fill: com.unisbadri.tokokita.staging
    • For all *-production configurations, fill: com.unisbadri.tokokita
  5. To change the app name dynamically, add a custom configuration (User-Defined Setting) named APP_DISPLAY_NAME in Xcode:
    • *-development $\rightarrow$ [DEV] Our Store
    • *-staging $\rightarrow$ [STG] Our Store
    • *-production $\rightarrow$ Our Store
  6. Open the ios/Runner/Info.plist file, change the CFBundleDisplayName and CFBundleName tags to read that custom variable:
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleName</key>
<string>$(APP_DISPLAY_NAME)</string>
  1. Create new Xcode Schemes for each environment (development, staging, production) and connect their build target actions to the appropriate configurations.

Different Launcher Icons Setup per Flavor #

App testers (QA teams) will be very helped if the app icons on their phone screens have clear visual markers (e.g., banner ribbons saying “DEV” or “STG”) so they don’t get mixed up when testing.

You can automate launcher icon creation for each flavor using the flutter_launcher_icons package.

1. pubspec.yaml Configuration #

Add the custom icon creation configuration per flavor below the dev_dependencies block in your pubspec.yaml file:

dev_dependencies:
  flutter_launcher_icons: ^0.13.1

# Icon configuration per flavor
flutter_launcher_icons:
  development:
    image_path: "assets/icons/launcher_dev.png"
    android: true
    ios: true
  staging:
    image_path: "assets/icons/launcher_stg.png"
    android: true
    ios: true
  production:
    image_path: "assets/icons/launcher_prod.png"
    android: true
    ios: true

2. Run the Icon Generator Command #

Run the following command in the terminal console to instruct the generator to automatically create native image assets into the Android res folder and iOS Assets:

flutter pub run flutter_launcher_icons:main

Separate Entry Point Management per Environment #

To cleanly separate app initialization logic (e.g., enabling Firebase Crashlytics error reporting only in production environments, or using mock databases in development environments), you’re recommended to use separate entry point (main) files.

We’ll split the entry point files into:

  • lib/main_development.dart
  • lib/main_staging.dart
  • lib/main_production.dart
  • lib/main_common.dart (Contains the shared initialization function)

1. Implementing lib/main_common.dart #

// lib/main_common.dart
import 'package:flutter/material.dart';
import 'core/config/app_config.dart';

/// The shared runner function called by each flavor entry point
void runSharedApp() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Shared initialization logic
  debugPrint('Running the app in environment mode: ${AppConfig.environment}');

  runApp(const MainApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text(AppConfig.isProduction ? 'Our Store' : '[DEBUG] Our Store')),
        body: Center(
          child: Text('API Gateway: ${AppConfig.apiUrl}'),
        ),
      ),
    );
  }
}

2. Implementing lib/main_development.dart #

// lib/main_development.dart
import 'main_common.dart';

void main() {
  // Here we can register development-specific mock dependencies
  // before calling the shared runner
  runSharedApp();
}

3. Implementing lib/main_production.dart #

// lib/main_production.dart
import 'main_common.dart';

void main() async {
  // Here we enable production logging, initialize crash reporting,
  // or do certificate validation before running the app
  runSharedApp();
}

To run or build releases using these specific entry points and flavors, use the -t (target) and --flavor parameters:

# Running the development version
flutter run -t lib/main_development.dart --flavor development --dart-define-from-file=.dart_define/development.json

# Building the production release AAB file
flutter build appbundle -t lib/main_production.dart --flavor production --dart-define-from-file=.dart_define/production.json

IDE Configuration (VS Code & Android Studio) #

So all your development team members can run the app in various environments easily without having to type long CLI commands in the terminal, you must provide launch configurations in the IDE editor.

1. VS Code Configuration #

Create a .vscode/launch.json file in your project folder:

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Development (Dev)",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_development.dart",
      "args": [
        "--flavor",
        "development",
        "--dart-define-from-file",
        ".dart_define/development.json"
      ]
    },
    {
      "name": "Run Staging (Stg)",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_staging.dart",
      "args": [
        "--flavor",
        "staging",
        "--dart-define-from-file",
        ".dart_define/staging.json"
      ]
    },
    {
      "name": "Run Production (Prod)",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_production.dart",
      "args": [
        "--flavor",
        "production",
        "--dart-define-from-file",
        ".dart_define/production.json"
      ]
    }
  ]
}

Now developers just press the F5 key in VS Code and select the target configuration from the drop-down menu to start debugging.


Configuration Security & Secret Key Management #

API keys, encryption keys, and third-party credential tokens inserted into configuration files must not be exposed to the public.

Here’s a strict guide to securing your configuration data:

  • Add to gitignore: Make sure the .dart_define/ folder and native credential files are added to your project’s .gitignore so they’re never uploaded to public git repositories.
    # .gitignore
    .dart_define/
    android/app/*.keystore
    android/app/*.jks
    ios/Runner/*.p12
    ios/Runner/google-services.json
    ios/Runner/GoogleService-Info.plist
    
  • Create Configuration Template Files: As a guide for other developers newly joining your project to compile their own definition files, create example files without real key values under Git supervision.
    // .dart_define/development.json.example
    {
      "APP_ENV": "development",
      "API_URL": "https://api-dev.example.com",
      "API_KEY": "ENTER_YOUR_DEVELOPMENT_KEY_HERE",
      "ENABLE_LOGS": "true",
      "TIMEOUT_DETIK": "10"
    }
    
  • Use GitHub Secrets on CI/CD: When processing automatic builds on CI/CD servers (like GitHub Actions), don’t store JSON configuration files in the repository. Store secret values in your CI/CD provider’s secret encryption settings, and use shell scripts to dynamically write (generate) JSON files right before the build compilation starts.

Summary #

  • Environment Separation: Dev, Staging, and Prod environments must be separated to maintain production data integrity and the confidentiality of your system security credentials.
  • –dart-define-from-file: Leverage compile-time JSON configuration files to neatly inject API gateway URL configuration data and other configuration variables into Dart code.
  • Native Product Flavors: Configure native flavors on Android (build.gradle) and Schemes on iOS (Xcode) to differentiate app names, Application IDs/Bundle IDs, and allow side-by-side installations.
  • Separate Entry Points: Use different entry files (main_development.dart, main_production.dart) to safely isolate mock dependency initialization from production release code.
  • Repository Security: Always make sure the .dart_define/ folder and native signing files (.keystore, .jks, .p12) are included in .gitignore to avoid credential leaks to the public.

← Previous: Accessibility   Next: Build & Release →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact