Flutter Flavors dev, staging and prod: solved exercise

Flutter Flavors dev, staging and prod: solved exercise

No real production app has a single environment. Flavors (or build variants) let you run the same codebase with different configurations without a single if in your business logic. The most portable approach in Flutter is using --dart-define or --dart-define-from-file.

Problem statement

Configure three environments — dev, staging, and prod — with:

  • A different API URL per environment.
  • A differentiated app name (visible in the device launcher).
  • Logging enabled only in dev.
  • A screen that displays the active configuration.

Create one JSON file per environment (do not version files containing secrets):

1
2
3
4
5
6
7
// envs/dev.json
{
  "APP_ENV": "dev",
  "APP_NAME": "MyApp Dev",
  "API_BASE_URL": "https://dev.api.example.com",
  "ENABLE_LOGGING": "true"
}
1
2
3
4
5
6
7
// envs/staging.json
{
  "APP_ENV": "staging",
  "APP_NAME": "MyApp Staging",
  "API_BASE_URL": "https://staging.api.example.com",
  "ENABLE_LOGGING": "true"
}
1
2
3
4
5
6
7
// envs/prod.json
{
  "APP_ENV": "prod",
  "APP_NAME": "MyApp",
  "API_BASE_URL": "https://api.example.com",
  "ENABLE_LOGGING": "false"
}

Add envs/ to .gitignore if the files contain secrets (or inject them as CI environment variables).

Solution: AppConfig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// lib/core/config/app_config.dart

/// Reads values injected by --dart-define-from-file at compile time.
/// Falls back to dev values if no flag is passed.
class AppConfig {
  static const env = String.fromEnvironment('APP_ENV', defaultValue: 'dev');
  static const appName = String.fromEnvironment('APP_NAME', defaultValue: 'MyApp Dev');
  static const apiBaseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://dev.api.example.com',
  );
  static const enableLogging =
      bool.fromEnvironment('ENABLE_LOGGING', defaultValue: true);

  static bool get isDev => env == 'dev';
  static bool get isStaging => env == 'staging';
  static bool get isProd => env == 'prod';
}

Solution: Conditional logger

1
2
3
4
5
6
7
8
9
// lib/core/logger.dart
import 'package:flutter/foundation.dart';
import 'app_config.dart';

void log(String message) {
  if (AppConfig.enableLogging) {
    debugPrint('[${AppConfig.env.toUpperCase()}] $message');
  }
}

Solution: main.dart and info screen

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// lib/main.dart
import 'package:flutter/material.dart';
import 'core/config/app_config.dart';
import 'core/logger.dart';

void main() {
  log('App started in environment: ${AppConfig.env}');
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: AppConfig.appName,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: AppConfig.isDev
              ? Colors.orange
              : AppConfig.isStaging
                  ? Colors.blue
                  : Colors.green,
        ),
      ),
      home: const EnvInfoPage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final color = Theme.of(context).colorScheme.primary;

    return Scaffold(
      appBar: AppBar(
        title: Text(AppConfig.appName),
        backgroundColor: color,
        foregroundColor: Colors.white,
      ),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _InfoRow(label: 'Environment', value: AppConfig.env.toUpperCase()),
            _InfoRow(label: 'API URL', value: AppConfig.apiBaseUrl),
            _InfoRow(label: 'Logging', value: AppConfig.enableLogging ? 'Enabled' : 'Disabled'),
            const SizedBox(height: 24),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
              decoration: BoxDecoration(
                color: color.withOpacity(0.1),
                borderRadius: BorderRadius.circular(6),
                border: Border.all(color: color),
              ),
              child: Text(
                AppConfig.isDev ? '⚠️ Development environment' : AppConfig.isProd ? '✅ Production' : '🔵 Staging',
                style: TextStyle(color: color, fontWeight: FontWeight.bold),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _InfoRow extends StatelessWidget {
  final String label;
  final String value;

  const _InfoRow({required this.label, required this.value});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 120,
            child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
          ),
          Expanded(child: Text(value)),
        ],
      ),
    );
  }
}

Running each environment

1
2
3
4
5
6
7
8
9
# Development
flutter run --dart-define-from-file=envs/dev.json

# Staging
flutter run --dart-define-from-file=envs/staging.json

# Production build
flutter build apk --dart-define-from-file=envs/prod.json
flutter build ios --dart-define-from-file=envs/prod.json

VS Code integration (launch.json)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// .vscode/launch.json
{
  "configurations": [
    {
      "name": "Dev",
      "request": "launch",
      "type": "dart",
      "args": ["--dart-define-from-file=envs/dev.json"]
    },
    {
      "name": "Staging",
      "request": "launch",
      "type": "dart",
      "args": ["--dart-define-from-file=envs/staging.json"]
    },
    {
      "name": "Prod",
      "request": "launch",
      "type": "dart",
      "args": ["--dart-define-from-file=envs/prod.json"]
    }
  ]
}

Expected result

  • flutter run --dart-define-from-file=envs/dev.json → orange screen with “DEV” and dev URL.
  • flutter run --dart-define-from-file=envs/prod.json → green screen with “PROD”, logging disabled.
  • Without the flag → falls back to dev values (safe default).

Common mistakes

  • Expecting hot reload to pick up dart-define changes: these values are resolved at compile time; changing the JSON file requires a full recompile, not just hot reload.
  • Versioning files with real secrets: add the .json env files to .gitignore; in CI inject them as runner secrets or environment variables.
  • Changing the app name only in Dart: the name visible in the device launcher must be set in AndroidManifest.xml (Android) and Info.plist (iOS). Use flutter_launcher_name or configure them manually.

Alternative: native flavors (Android/iOS)

For deeper changes (different app icons, separate Xcode targets), you need native flavors in Gradle and Xcode. --dart-define-from-file covers 80% of use cases with far less configuration.

Practical use

This is the first thing you configure in a new production project, alongside the base architecture. It prevents production API calls from development builds and makes QA much easier.

Guided practice and next step

FAQ

Does dart-define completely replace native flavors?

For most apps, yes. Native flavors are only necessary when you need separate app icons, distinct bundle IDs, or deep per-environment native configurations on Android/iOS.

Are dart-define values safe for API secrets?

Not completely. They are embedded in the binary and extractable with analysis tools. For sensitive secrets, use a backend intermediary or services like Firebase Remote Config.

Does it work with GitHub Actions?

Yes. Pass values as environment variables in the workflow and generate the environment JSON dynamically before the build: echo '{"API_BASE_URL":"${{ secrets.API_URL }}"}' > envs/prod.json.