Managing multiple environments in a Flutter app—like local, staging, and production—can be tricky if you don’t have a clean setup. In this blog, I’ll walk you through how I implemented flavors in my Flutter app, covering both Android and iOS, including MethodChannels, Xcode changes, and Flutter setup.
Why Flavors?
Flavors allow you to:
- Keep separate configurations for local, staging, and production.
- Use different API URLs, app names, and assets for each environment.
- Avoid accidentally hitting production APIs during development.
- Customize app behavior per environment (e.g., showing banners for staging/local builds).
Android Setup
For Android, I used product flavors in app/build.gradle. Here’s a simplified setup:
android {
...
flavorDimensions "default"
productFlavors {
create("local") {
dimension = "default"
applicationId = "com.example.app"
resValue("string", "app_name", "App Local")
resValue("string", "flavor_name", "local")
resValue("string", "base_url", "https://local.app")
}
create("staging") {
dimension = "default"
applicationId = "com.example.app"
resValue("string", "app_name", "App Staging")
resValue("string", "flavor_name", "staging")
resValue("string", "base_url", "https://stg.app.com")
}
create("production") {
dimension = "default"
applicationId = "com.example.app"
resValue("string", "app_name", "AppName")
resValue("string", "flavor_name", "production")
resValue("string", "base_url", "https://prod.app.com")
}
}
}
Reading Flavors in Kotlin
In your MainActivity.kt, you can read flavor-specific values using a MethodChannel:
class MainActivity : FlutterActivity() {
private val CHANNEL = "app_config"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getFlavor" -> result.success(getString(R.string.flavor_name))
"getBaseUrl" -> result.success(getString(R.string.base_url))
else -> result.notImplemented()
}
}
}
}
iOS Setup
iOS requires a bit more configuration with schemes, configurations, and .xcconfig files.
Step 1: Create .xcconfig Files
Inside ios/Flutter, create three files:
local.xcconfig
staging.xcconfig
production.xcconfig
Example for production.xcconfig:
#include "Flutter/Generated.xcconfig"
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release-production.xcconfig"
APP_NAME = Jumpick
BASE_URL_SCHEME = https
BASE_URL_HOST = prod.app.com
Similarly, configure local.xcconfig and staging.xcconfig with the respective names and URLs as we did for android.
Step 2: Info.plist Changes
In Info.plist, use placeholders for app name and base URL:
<key>CFBundleDisplayName</key>
<string>$(APP_NAME)</string>
<key>BASE_URL</key>
<string>$(BASE_URL_SCHEME)://$(BASE_URL_HOST)</string>
Step 3: Create MethodChannel in Swift
In AppDelegate.swift, fetch the base URL for Flutter:
// ✅ Create channel
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(name: "app_config", binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
if call.method == "getBaseUrl" {
if let baseUrl = Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String {
result(baseUrl)
} else {
result(nil)
}
} else {
result(FlutterMethodNotImplemented)
}
}
}
Step 4: Schemes and Configurations
- Create new schemes for each flavor (Runner-local, Runner-staging, Runner-production).
- Add Debug/Release configurations per flavor in Xcode.
- Map each configuration to the corresponding .xcconfig file.
See below images for reference
Flutter Setup
Create a singleton AppConfig to fetch the base URL:
import 'dart:io';
import 'package:flutter/services.dart';
class AppConfig {
static const _defaultBaseUrl = "https://prod.app.com";
static const MethodChannel _channel = MethodChannel("app_config");
static String? _cachedBaseUrl;
static Future<String> get baseUrl async {
if (_cachedBaseUrl != null) return _cachedBaseUrl!;
if (Platform.isAndroid || Platform.isIOS) {
try {
_cachedBaseUrl = await _channel.invokeMethod<String>("getBaseUrl") ?? _defaultBaseUrl;
} on PlatformException {
_cachedBaseUrl = _defaultBaseUrl;
}
} else {
_cachedBaseUrl = _defaultBaseUrl;
}
return _cachedBaseUrl!;
}
}
Show Environment Banner
In main.dart, show a small banner for local/staging:
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'Demo App',
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeController.theme,
initialRoute: AppPages.initial,
getPages: AppPages.routes,
builder: (context, child) {
final originalScaler = MediaQuery.of(context).textScaler;
final clampedScaler = originalScaler.clamp(
minScaleFactor: 1.0,
maxScaleFactor: 1.4,
);
Widget content = MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: clampedScaler),
child: child!,
);
// Add banner only if staging
if (ApiConstants.baseUrl.contains("stg")) {
content = Banner(
message: "STAGING",
location: BannerLocation.topEnd,
color: Colors.orange.withValues(alpha: 0.8),
textStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
child: content,
);
}
else if (ApiConstants.baseUrl.contains("local")) {
content = Banner(
message: "LOCAL",
location: BannerLocation.topEnd,
color: Colors.green.withValues(alpha: 0.85),
textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
child: content,
);
}
return content;
},
);
}
This makes it visually clear which environment is running. See below images for reference.
To install your app through android studio or xcode refer below screenshots. For android create new configurations from top bar.
TO BUILD APP THROUGH COMMANDS:
ANDROID
flutter build appbundle \
--flavor staging \
-t lib/main.dart \
--release
IOS
xcodebuild clean -workspace Runner.xcworkspace \
-scheme Runner-staging \
-configuration Release-staging
# Archive the build with explicit overrides
xcodebuild -workspace Runner.xcworkspace \
-scheme Runner-staging \
-configuration Release-staging \
-archivePath build/Runner-staging.xcarchive \
DEVELOPMENT_TEAM="$DEV_TEAM_ID" \
CODE_SIGN_STYLE=Automatic \
archive
Wrap Up
- Setting up flavors properly saves a lot of headaches. With the above setup:
- Android flavors are easy via build.gradle.
- iOS flavors are manageable with .xcconfig files and schemes.
- Flutter can dynamically fetch the correct base URL or flavor.
- You can display banners to avoid confusion during testing.
- This setup makes switching environments seamless, helps with testing, and prevents accidental API calls to production during development.







Top comments (0)