When to use this setup
A Flutter Android project often starts with one main.dart, one API base URL, and one package name. That is fine for a prototype, but it becomes fragile as soon as QA needs installable builds, product managers need a staging package, and CI needs to produce the same artifact that developers build locally. Manual edits to API URLs or logging flags are one of the easiest ways to ship the wrong build. This article describes a practical environment setup for a real Flutter Android app: Android product flavors handle package-level differences, while --dart-define-from-file injects Dart-side runtime configuration at build time.
The setup is meant for teams that already have a working Flutter app with networking, local storage, and a basic release flow. It is the next engineering layer after the app can run. The goal is not to create a complicated enterprise template. The goal is to make dev, staging, and production builds reproducible, inspectable, and hard to mix up. It connects naturally with the existing Stepnex article on Android offline-first synchronization with Room and WorkManager: offline-first work keeps data reliable, while environment configuration keeps the release path reliable.
Flutter's official documentation describes Android flavors as Android product flavors and shows commands such as flutter run --flavor and flutter build apk --flavor. Dart compile-time environment declarations are read with String.fromEnvironment, bool.fromEnvironment, or similar constant constructors. In practice, use each tool for the job it fits best. Product flavors should own Android installation and packaging differences. Dart defines should own app behavior that is safe to compile into the client.
Configuration boundaries
Before writing code, define a rule that the team can repeat during review. Put Android installation details in flavors: package suffixes, app display names, icons, signing boundaries, and store-facing variants. Put Dart application settings in JSON files passed by --dart-define-from-file: API base URL, log level, mock switch, telemetry switch, feature flag defaults, and public client identifiers.
Do not treat dart defines as a secret store. Anything compiled into a mobile app can be inspected by a determined user. It is acceptable to store environment names and public endpoints. It is not acceptable to store admin tokens, private payment keys, long-lived service credentials, or server-only secrets. Those belong on the backend. The app should receive only short-lived sessions, public keys, or tightly scoped client identifiers.
A small but clear directory structure is enough:
app/
lib/
main.dart
bootstrap.dart
core/config/app_config.dart
core/logging/app_logger.dart
env/
dev.json
staging.json
prod.json
android/app/build.gradle
.github/workflows/android.yml
This structure lets a new teammate locate the important pieces quickly. Native package identity lives in Gradle. Dart configuration lives in env/*.json. Startup wiring lives in bootstrap.dart. Logging policy lives in app_logger.dart.
Step 1: add Android product flavors
The following steps start at the Android package boundary because install behavior is the first thing users and testers notice. If the package names are wrong, all later configuration work becomes harder to trust.
The following example uses the Groovy Gradle file still common in Flutter projects. Kotlin DSL uses a different syntax, but the concepts are the same: a flavor dimension, three product flavors, package suffixes for non-production builds, and resource values for app names.
android {
namespace "com.stepnex.shop"
compileSdk flutter.compileSdkVersion
defaultConfig {
applicationId "com.stepnex.shop"
minSdk flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
versionCode flutter.versionCode
versionName flutter.versionName
}
flavorDimensions "env"
productFlavors {
dev {
dimension "env"
applicationIdSuffix ".dev"
resValue "string", "app_name", "Stepnex Dev"
}
staging {
dimension "env"
applicationIdSuffix ".staging"
resValue "string", "app_name", "Stepnex Staging"
}
prod {
dimension "env"
resValue "string", "app_name", "Stepnex"
}
}
}
Then make the Android manifest read the app name from resources:
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher">
</application>
Validate with real commands instead of assuming the Gradle file is correct:
flutter clean
flutter pub get
flutter run --flavor dev --dart-define-from-file=env/dev.json
flutter build apk --flavor staging --dart-define-from-file=env/staging.json
flutter build appbundle --flavor prod --dart-define-from-file=env/prod.json
Install dev, staging, and production builds on the same Android device. Dev and staging should not overwrite the production app. The app drawer should show distinct names. adb shell pm list packages | grep stepnex should reveal different package names for non-production builds. That is the first practical validation step.
Step 2: inject Dart configuration from JSON
Create one JSON file per environment. Keep the values public and build-time oriented:
{
"APP_ENV": "staging",
"API_BASE_URL": "https://staging-api.example.com",
"LOG_LEVEL": "debug",
"ENABLE_MOCK": "false",
"ENABLE_VERBOSE_HTTP_LOG": "true"
}
Read these values through a single typed configuration object. The const keyword matters because String.fromEnvironment is a compile-time mechanism in Flutter builds.
enum AppEnv { dev, staging, prod }
class AppConfig {
const AppConfig({
required this.env,
required this.apiBaseUrl,
required this.logLevel,
required this.enableMock,
required this.enableVerboseHttpLog,
});
final AppEnv env;
final Uri apiBaseUrl;
final String logLevel;
final bool enableMock;
final bool enableVerboseHttpLog;
static const _envName = String.fromEnvironment('APP_ENV', defaultValue: 'dev');
static const _apiBaseUrl = String.fromEnvironment('API_BASE_URL');
static const _logLevel = String.fromEnvironment('LOG_LEVEL', defaultValue: 'info');
static const _enableMock = bool.fromEnvironment('ENABLE_MOCK');
static const _verboseHttp = bool.fromEnvironment('ENABLE_VERBOSE_HTTP_LOG');
factory AppConfig.fromBuildEnv() {
if (_apiBaseUrl.isEmpty) {
throw StateError('Missing API_BASE_URL. Check --dart-define-from-file.');
}
final env = switch (_envName) {
'prod' => AppEnv.prod,
'staging' => AppEnv.staging,
_ => AppEnv.dev,
};
return AppConfig(
env: env,
apiBaseUrl: Uri.parse(_apiBaseUrl),
logLevel: _logLevel,
enableMock: _enableMock,
enableVerboseHttpLog: _verboseHttp,
);
}
}
Use bootstrap.dart to pass this configuration to the app root, repository layer, HTTP client, and logger:
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
final config = AppConfig.fromBuildEnv();
final logger = AppLogger(level: config.logLevel);
logger.info('app_env=${config.env.name} api_host=${config.apiBaseUrl.host}');
runApp(AppRoot(config: config, logger: logger));
}
The log line is intentionally limited. It shows the environment and host, not tokens, full headers, user data, or request bodies. Production builds should keep only the logs needed for diagnosis. Development builds can use more verbose HTTP logs.
Step 3: make local commands repeatable
The most common pitfall is not the Gradle syntax. It is command drift. One developer builds staging with a copied command from chat, another uses an Android Studio run configuration, and CI uses a third version. Wrap the command in a small script and review that script like production code.
A PowerShell version for Windows teams can look like this:
param(
[ValidateSet('dev','staging','prod')]
[string]$Env = 'dev',
[ValidateSet('apk','appbundle')]
[string]$Target = 'apk'
)
$defineFile = "env/$Env.json"
if (-not (Test-Path $defineFile)) {
throw "Missing define file: $defineFile"
}
flutter pub get
flutter test
flutter build $Target --flavor $Env --dart-define-from-file=$defineFile
QA can request a staging build and the developer runs:
./tool/build_android.ps1 -Env staging -Target apk
For the production release candidate:
./tool/build_android.ps1 -Env prod -Target appbundle
Validate the script in a clean working tree. It should fail clearly when a JSON file is missing, when tests fail, or when Flutter build fails. The output path should be predictable enough for CI or a release note to reference.
Step 4: use the same flow in CI
CI should not invent a separate build path. It should run the same checks and the same flavor/define pairing. A GitHub Actions workflow can start this way:
name: android-build
on:
workflow_dispatch:
inputs:
env:
type: choice
options: [dev, staging, prod]
default: staging
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: flutter build apk --flavor ${{ inputs.env }} --dart-define-from-file=env/${{ inputs.env }}.json
- uses: actions/upload-artifact@v4
with:
name: android-${{ inputs.env }}-apk
path: build/app/outputs/flutter-apk/*.apk
For production signing, keep keystores and passwords in CI secrets, not in the repository. CI logs should include the Flutter version, selected flavor, selected define file, test result, and artifact name. Those details make a release artifact auditable later.
Pitfalls to avoid
The first pitfall is sharing one package name across all environments. That makes staging overwrite production and hides mistakes until late testing. The second pitfall is leaving production with debug logging. If the HTTP interceptor prints headers, a production debug build can expose more than intended. The third pitfall is allowing mock data in the production flavor. The mock switch should be visible in startup logs and disabled in the production JSON file.
Another pitfall is printing too much configuration. For troubleshooting, log the environment, API host, build number, and flavor. Do not log full tokens, private URLs with credentials, user identifiers, or complete request headers. Also avoid validating only flutter run. A release app bundle can expose issues that a debug run misses: signing, resource shrinking, R8 behavior, target SDK checks, and store-facing manifest values.
Finally, keep Android Studio run configurations aligned with the command line. If the IDE starts dev but the terminal starts staging, developers will report bugs that nobody else can reproduce. Store launch configurations when possible, or document the exact --flavor and --dart-define-from-file arguments.
Validation checklist
flutter analyzeandflutter testpass before building an artifact.flutter run --flavor dev --dart-define-from-file=env/dev.jsonstarts and shows the dev app name.- Staging and production can be installed side by side on a real Android device.
- The production build has the expected package name, app name, icon, signing config, and version number.
- Startup logs show environment, API host, and build number, but not secrets or full request headers.
- API requests reach the intended backend, verified by a proxy, backend access log, or test account.
- CI artifact names include the environment.
- Missing
API_BASE_URLfails fast instead of silently using a default endpoint.
Review checklist after an environment incident
When a staging build connects to production or a production build emits debug logs, do not only edit the JSON file. Review four layers: the command that triggered the build, the selected flavor, the selected env/*.json file, and the startup log printed by the app. If any layer lacks evidence, add it before the next release.
After the review, make three durable changes. First, move copied commands into a script. Second, standardize the startup log line so it is searchable in device logs. Third, name CI artifacts with the environment and commit SHA. For production, add a release smoke test: install the app bundle, capture the app name and version screen, and confirm from backend logs that requests are reaching the production host. Environment configuration is valuable when it makes every build verifiable, not when it makes the project look more complex.