Lesson 51 of 51 advanced

Config, Build & Release

Shipping Your App to the World

Open interactive version (quiz + challenge)

Real-world analogy

Building an app is like baking a cake for a competition. During practice (development), you taste-test constantly and change recipes. For the competition (release), you follow the exact recipe, frost it perfectly (sign it), put it in a proper box (build), and submit it to the judges (app stores). The TeamMvpKitConfig pattern is like having separate recipe cards for practice rounds and the final competition!

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

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. 1. Comment: TeamMvpKitConfig holds all environment settings
  2. 2. Class declaration with five configuration fields
  3. 3. App name shown in the device app drawer
  4. 4. API base URL that Dio uses for all requests
  5. 5. Environment name (dev, staging, prod) for identification
  6. 6. Whether to enable debug logging
  7. 7. Whether to enable crash reporting
  8. 8.
  9. 9. Const constructor with required and optional parameters
  10. 10. Closing the constructor
  11. 11.
  12. 12. Static const for the development configuration
  13. 13. Dev app name includes 'Dev' suffix to distinguish on device
  14. 14. Dev API points to the development server
  15. 15. Environment is dev
  16. 16. Logging enabled for debugging
  17. 17. Crashlytics disabled in dev to avoid noise
  18. 18. Closing dev config
  19. 19.
  20. 20. Static const for the staging configuration
  21. 21. Staging app name
  22. 22. Staging API URL
  23. 23. Environment is staging
  24. 24. Logging enabled for debugging staging issues
  25. 25. Crashlytics enabled to catch staging crashes
  26. 26. Closing staging config
  27. 27.
  28. 28. Static const for the production configuration
  29. 29. Production app name (clean, no suffix)
  30. 30. Production API URL
  31. 31. Environment is prod
  32. 32. Logging disabled for security and performance
  33. 33. Crashlytics enabled to catch production crashes
  34. 34. Closing prod config and TeamMvpKitConfig class
  35. 35.
  36. 36. Comment: Bootstrap function called by each entry point
  37. 37. bootstrap takes a config and sets up the entire app
  38. 38. Ensure Flutter binding is ready
  39. 39.
  40. 40. Initialize Firebase with platform-specific options
  41. 41. Passing the generated Firebase options
  42. 42. Closing Firebase init
  43. 43.
  44. 44. Enable or disable Crashlytics based on config
  45. 45. Pass the config flag to Crashlytics
  46. 46. Closing the setCrashlyticsCollectionEnabled call
  47. 47.
  48. 48. If Crashlytics is enabled, set up the error handler
  49. 49. Route all Flutter errors to Crashlytics
  50. 50. Closing the if block
  51. 51.
  52. 52. Register the config as a singleton in GetIt DI
  53. 53. Initialize all other dependencies
  54. 54.
  55. 55. Start the app with the config
  56. 56. Closing bootstrap
  57. 57.
  58. 58. Comment: Entry point files for each environment
  59. 59. main_dev.dart calls bootstrap with dev config
  60. 60.
  61. 61. main_staging.dart calls bootstrap with staging config
  62. 62.
  63. 63. main_prod.dart calls bootstrap with prod config
  64. 64.
  65. 65. Run command specifying the target entry file
  66. 66. Build command specifying the target for production
  67. 67.
  68. 68. Comment: Dio module uses config for its base URL
  69. 69. @module for third-party Dio registration
  70. 70. Abstract class NetworkModule
  71. 71. @singleton for one Dio instance
  72. 72. Method receives config and tokenStorage from DI
  73. 73. Create Dio with base URL from config
  74. 74. Set the API base URL from the config object
  75. 75. Set connection timeout
  76. 76. Set receive timeout
  77. 77. Default JSON headers
  78. 78. Closing the BaseOptions and Dio constructor
  79. 79.
  80. 80. Add the auth interceptor for token management
  81. 81. Closing interceptor add
  82. 82.
  83. 83. Only add logging if the config enables it
  84. 84. Add LogInterceptor with request and response body logging
  85. 85. Closing the logging configuration
  86. 86.
  87. 87. Return the fully configured Dio instance
  88. 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?
Think about what is missing before runApp and whether the config is accessible to the rest of the app...
Show answer
Three issues: 1) WidgetsFlutterBinding.ensureInitialized() is missing before creating Dio. 2) The config is created locally in main() but not registered in DI, so other classes cannot access it. Register it with getIt.registerSingleton(config). 3) Firebase.initializeApp() is missing if the app uses Firebase. 4) main should be async and use await for async operations.

Explain like I'm 5

Imagine you are a toy maker. While building toys in your workshop (development), you use cheap materials and test a lot. When the toy is ready for the store (production), you use the best materials, put it in a nice box, add a label with your name (signing), and send it to the toy store (app store). The config pattern is like having two recipe books -- one for workshop experiments and one for the final product. You never accidentally ship a workshop prototype to the store!

Fun fact

The first Android app to reach 1 billion downloads was Google Maps in 2012. Today, the Google Play Store has over 3.5 million apps and the Apple App Store has over 1.8 million. Flutter apps make up a growing percentage -- companies like BMW, Alibaba, Google Pay, and eBay all use Flutter for their production apps. Your app could be next!

Hands-on challenge

Create a TeamMvpKitConfig class with dev and prod static constants. Create main_dev.dart and main_prod.dart entry points. Create a bootstrap function that initializes Firebase and registers the config in GetIt. Build a release APK with flutter build apk --release -t lib/main_prod.dart.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart