Config, Build & Release
Shipping Your App to the World
Open interactive version (quiz + challenge)Real-world analogy
What is it?
App configuration, building, and releasing covers everything from managing environment-specific settings (dev vs prod URLs) to signing, building, and publishing your app to the Google Play Store and Apple App Store. team_mvp_kit uses the TeamMvpKitConfig pattern with separate entry points per environment and centralized config registered in the DI container.
Real-world relevance
team_mvp_kit's build pipeline: developers run main_dev.dart locally with dev API and logging enabled. CI runs tests on every pull request. When merging to main, the pipeline builds with main_prod.dart -- production API, Crashlytics enabled, logging disabled. The signed APK/AAB and IPA are uploaded to the stores. Environment switching is a config change, not a code change.
Key points
- TeamMvpKitConfig Pattern — team_mvp_kit uses a centralized config class that holds all environment-specific settings: API base URL, Firebase options, feature flags, and app metadata. Different environments (dev, staging, production) use different config instances. This pattern makes environment switching clean and safe.
- Environment Entry Points — Create separate main files for each environment. main_dev.dart uses the dev config, main_prod.dart uses the prod config. Each calls a shared bootstrap function with its config. This lets you switch environments by changing which file you run, without modifying any code.
- Using Config in DI — Pass the config object into your dependency injection setup. Register it as a singleton so any service can access environment settings. Dio uses config.apiBaseUrl, Crashlytics checks config.enableCrashlytics, and LogInterceptor checks config.enableLogging.
- Dart Define for Build Variants — Use --dart-define to pass environment variables at build time without multiple main files. Access them with String.fromEnvironment(). This approach is popular for CI/CD pipelines where the environment is set by the build script.
- Building for Android (APK and AAB) — Use flutter build apk for a debug/test APK and flutter build appbundle for the Play Store. The AAB (Android App Bundle) is required by Google Play -- it generates optimized APKs for each device configuration, resulting in smaller downloads for users.
- Android App Signing — Release builds must be signed with a keystore. Create a keystore file once and configure it in android/app/build.gradle. Never commit the keystore or passwords to git. Store them securely and reference them from a local key.properties file.
- Building for iOS — Use flutter build ios to create the iOS app. Then open Xcode to archive and upload to App Store Connect. iOS requires an Apple Developer account ($99/year), provisioning profiles, and code signing certificates. Xcode handles most of this through automatic signing.
- App Version and Build Number — Set your app version in pubspec.yaml. The format is version: MAJOR.MINOR.PATCH+BUILD_NUMBER. Major for breaking changes, minor for new features, patch for bug fixes. The build number must increment with every upload to the app stores.
- Pre-Release Checklist — Before submitting to app stores: update version number, run all tests, test on real devices (not just emulators), check app size, verify all API endpoints point to production, disable debug logging, enable crash reporting, test deep links, and prepare store listing screenshots and descriptions.
- CI/CD with GitHub Actions — Automate builds and testing with GitHub Actions. On every pull request, run tests. On merge to main, build the release and optionally deploy to app stores via Fastlane. team_mvp_kit uses CI to ensure no broken code reaches the main branch.
Code example
// 1. TeamMvpKitConfig (environment configuration)
class TeamMvpKitConfig {
final String appName;
final String apiBaseUrl;
final String environment;
final bool enableLogging;
final bool enableCrashlytics;
const TeamMvpKitConfig({
required this.appName,
required this.apiBaseUrl,
required this.environment,
this.enableLogging = false,
this.enableCrashlytics = true,
});
static const dev = TeamMvpKitConfig(
appName: 'MyApp Dev',
apiBaseUrl: 'https://dev-api.example.com/v1',
environment: 'dev',
enableLogging: true,
enableCrashlytics: false,
);
static const staging = TeamMvpKitConfig(
appName: 'MyApp Staging',
apiBaseUrl: 'https://staging-api.example.com/v1',
environment: 'staging',
enableLogging: true,
enableCrashlytics: true,
);
static const prod = TeamMvpKitConfig(
appName: 'MyApp',
apiBaseUrl: 'https://api.example.com/v1',
environment: 'prod',
enableLogging: false,
enableCrashlytics: true,
);
}
// 2. Bootstrap function shared by all entry points
Future<void> bootstrap(TeamMvpKitConfig config) async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configure Crashlytics based on config
await FirebaseCrashlytics.instance
.setCrashlyticsCollectionEnabled(
config.enableCrashlytics,
);
if (config.enableCrashlytics) {
FlutterError.onError =
FirebaseCrashlytics.instance.recordFlutterError;
}
// Register config in DI, then init all dependencies
getIt.registerSingleton<TeamMvpKitConfig>(config);
await configureDependencies();
runApp(MyApp(config: config));
}
// 3. Entry point files
// main_dev.dart:
// void main() => bootstrap(TeamMvpKitConfig.dev);
//
// main_staging.dart:
// void main() => bootstrap(TeamMvpKitConfig.staging);
//
// main_prod.dart:
// void main() => bootstrap(TeamMvpKitConfig.prod);
//
// Run: flutter run -t lib/main_dev.dart
// Build: flutter build apk -t lib/main_prod.dart
// 4. Using config in Dio module
@module
abstract class NetworkModule {
@singleton
Dio dio(
TeamMvpKitConfig config,
TokenStorage tokenStorage,
) {
final dio = Dio(BaseOptions(
baseUrl: config.apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
dio.interceptors.add(
AuthInterceptor(tokenStorage, dio),
);
if (config.enableLogging) {
dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
}
return dio;
}
}Line-by-line walkthrough
- 1. Comment: TeamMvpKitConfig holds all environment settings
- 2. Class declaration with five configuration fields
- 3. App name shown in the device app drawer
- 4. API base URL that Dio uses for all requests
- 5. Environment name (dev, staging, prod) for identification
- 6. Whether to enable debug logging
- 7. Whether to enable crash reporting
- 8.
- 9. Const constructor with required and optional parameters
- 10. Closing the constructor
- 11.
- 12. Static const for the development configuration
- 13. Dev app name includes 'Dev' suffix to distinguish on device
- 14. Dev API points to the development server
- 15. Environment is dev
- 16. Logging enabled for debugging
- 17. Crashlytics disabled in dev to avoid noise
- 18. Closing dev config
- 19.
- 20. Static const for the staging configuration
- 21. Staging app name
- 22. Staging API URL
- 23. Environment is staging
- 24. Logging enabled for debugging staging issues
- 25. Crashlytics enabled to catch staging crashes
- 26. Closing staging config
- 27.
- 28. Static const for the production configuration
- 29. Production app name (clean, no suffix)
- 30. Production API URL
- 31. Environment is prod
- 32. Logging disabled for security and performance
- 33. Crashlytics enabled to catch production crashes
- 34. Closing prod config and TeamMvpKitConfig class
- 35.
- 36. Comment: Bootstrap function called by each entry point
- 37. bootstrap takes a config and sets up the entire app
- 38. Ensure Flutter binding is ready
- 39.
- 40. Initialize Firebase with platform-specific options
- 41. Passing the generated Firebase options
- 42. Closing Firebase init
- 43.
- 44. Enable or disable Crashlytics based on config
- 45. Pass the config flag to Crashlytics
- 46. Closing the setCrashlyticsCollectionEnabled call
- 47.
- 48. If Crashlytics is enabled, set up the error handler
- 49. Route all Flutter errors to Crashlytics
- 50. Closing the if block
- 51.
- 52. Register the config as a singleton in GetIt DI
- 53. Initialize all other dependencies
- 54.
- 55. Start the app with the config
- 56. Closing bootstrap
- 57.
- 58. Comment: Entry point files for each environment
- 59. main_dev.dart calls bootstrap with dev config
- 60.
- 61. main_staging.dart calls bootstrap with staging config
- 62.
- 63. main_prod.dart calls bootstrap with prod config
- 64.
- 65. Run command specifying the target entry file
- 66. Build command specifying the target for production
- 67.
- 68. Comment: Dio module uses config for its base URL
- 69. @module for third-party Dio registration
- 70. Abstract class NetworkModule
- 71. @singleton for one Dio instance
- 72. Method receives config and tokenStorage from DI
- 73. Create Dio with base URL from config
- 74. Set the API base URL from the config object
- 75. Set connection timeout
- 76. Set receive timeout
- 77. Default JSON headers
- 78. Closing the BaseOptions and Dio constructor
- 79.
- 80. Add the auth interceptor for token management
- 81. Closing interceptor add
- 82.
- 83. Only add logging if the config enables it
- 84. Add LogInterceptor with request and response body logging
- 85. Closing the logging configuration
- 86.
- 87. Return the fully configured Dio instance
- 88. Closing the dio method and NetworkModule
Spot the bug
class AppConfig {
static const dev = AppConfig(
apiUrl: 'https://dev-api.example.com',
enableLogging: true,
);
final String apiUrl;
final bool enableLogging;
const AppConfig({required this.apiUrl, required this.enableLogging});
}
void main() {
final config = AppConfig.dev;
final dio = Dio(BaseOptions(baseUrl: config.apiUrl));
if (config.enableLogging) {
dio.interceptors.add(LogInterceptor());
}
runApp(MyApp());
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter Build and Release for Android (Flutter Official)
- Flutter Build and Release for iOS (Flutter Official)
- Flutter Flavors (Build Variants) (Flutter Official)