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:
- 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.
- 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.
- 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-fileparameter 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 StorevsOur 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’sgoogle-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:
- Open the iOS project in Xcode (
open ios/Runner.xcworkspace). - Select the Runner project in the left panel $\rightarrow$ Info tab $\rightarrow$ find the Configurations section.
- Duplicate the existing configurations by clicking the + icon:
- Duplicate
Debuginto:Debug-development,Debug-staging,Debug-production. - Duplicate
Releaseinto:Release-development,Release-staging,Release-production. - Duplicate
Profileinto:Profile-development,Profile-staging,Profile-production.
- Duplicate
- Select Build Settings $\rightarrow$ find Product Bundle Identifier. Adjust the package ID for each configuration:
- For all
*-developmentconfigurations, fill:com.unisbadri.tokokita.dev - For all
*-stagingconfigurations, fill:com.unisbadri.tokokita.staging - For all
*-productionconfigurations, fill:com.unisbadri.tokokita
- For all
- To change the app name dynamically, add a custom configuration (User-Defined Setting) named
APP_DISPLAY_NAMEin Xcode:*-development$\rightarrow$[DEV] Our Store*-staging$\rightarrow$[STG] Our Store*-production$\rightarrow$Our Store
- Open the
ios/Runner/Info.plistfile, change theCFBundleDisplayNameandCFBundleNametags to read that custom variable:
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleName</key>
<string>$(APP_DISPLAY_NAME)</string>
- 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.dartlib/main_staging.dartlib/main_production.dartlib/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.gitignoreso 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.gitignoreto avoid credential leaks to the public.