Flavors dev, staging y prod en Flutter: ejercicio resuelto

Flavors dev, staging y prod en Flutter: ejercicio resuelto

Ninguna app de producción real tiene un solo entorno. Flavors (o variantes de build) permiten ejecutar la misma base de código con configuraciones distintas sin un solo if en la lógica de negocio. La forma más portable en Flutter es usar --dart-define o --dart-define-from-file.

Enunciado

Configura tres entornos — dev, staging y prod — con:

  • URL de API distinta por entorno.
  • Nombre de app diferenciado (visible en el lanzador del dispositivo).
  • Logging habilitado solo en dev.
  • Una pantalla que muestre la configuración activa.

Enfoque recomendado: dart-define-from-file

Crea un archivo JSON por entorno (no los versiones con secretos):

1
2
3
4
5
6
7
// envs/dev.json
{
  "APP_ENV": "dev",
  "APP_NAME": "MiApp 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": "MiApp 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": "MiApp",
  "API_BASE_URL": "https://api.example.com",
  "ENABLE_LOGGING": "false"
}

Añade envs/ al .gitignore si contienen secretos (o usa variables de entorno en CI).

Solución: 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

/// Lee los valores inyectados por --dart-define-from-file en tiempo de compilación.
/// Si no se pasan, usa los valores de dev como fallback seguro.
class AppConfig {
  static const env = String.fromEnvironment('APP_ENV', defaultValue: 'dev');
  static const appName = String.fromEnvironment('APP_NAME', defaultValue: 'MiApp 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';
}

Solución: Logger condicional

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');
  }
}

Solución: main.dart y pantalla de info

 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 iniciada en entorno: ${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: 'Entorno', value: AppConfig.env.toUpperCase()),
            _InfoRow(label: 'API URL', value: AppConfig.apiBaseUrl),
            _InfoRow(label: 'Logging', value: AppConfig.enableLogging ? 'Activado' : 'Desactivado'),
            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 ? '⚠️ Entorno de desarrollo' : AppConfig.isProd ? '✅ Producción' : '🔵 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: 100,
            child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
          ),
          Expanded(child: Text(value)),
        ],
      ),
    );
  }
}

Cómo ejecutar cada entorno

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

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

# Producción (build)
flutter build apk --dart-define-from-file=envs/prod.json
flutter build ios --dart-define-from-file=envs/prod.json

Integración con VS Code (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"]
    }
  ]
}

Resultado esperado

  • flutter run --dart-define-from-file=envs/dev.json → pantalla naranja con “DEV” y URL de dev.
  • flutter run --dart-define-from-file=envs/prod.json → pantalla verde con “PROD”, logging desactivado.
  • Sin pasar el flag → fallback a valores de dev (safe default).

Errores frecuentes

  • Leer String.fromEnvironment en tiempo de ejecución con const: estos valores se resuelven en tiempo de compilación; si cambias el json tienes que recompilar, no basta con hot reload.
  • Versionar archivos con secretos: usa .gitignore para los archivos .json con credenciales reales; en CI inyéctalos como variables de entorno o secretos del runner.
  • Cambiar nombre de app solo en Dart: el nombre visible en el lanzador del dispositivo se configura en AndroidManifest.xml (Android) e Info.plist (iOS). Usa herramientas como flutter_launcher_name o configura manualmente.

Alternativa: native flavors (Android/iOS)

Para cambios más profundos (íconos de app distintos, targets de Xcode separados), necesitas native flavors en Gradle y Xcode. --dart-define-from-file cubre el 80% de los casos con mucho menos configuración.

Aplicación práctica

Es la primera cosa que configuras en un proyecto nuevo de producción, junto a la arquitectura base. Impide que se produzcan llamadas a producción desde una build de desarrollo y facilita el QA.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿dart-define reemplaza completamente los native flavors?

Para la mayoría de apps, sí. Los native flavors son necesarios si quieres íconos distintos, bundle IDs separados o configuraciones nativas por entorno en Android/iOS.

¿Los valores de dart-define son seguros para secretos de API?

No completamente. Están embebidos en el binario y son extraíbles con herramientas de análisis. Para secretos sensibles usa un servidor intermedio o servicios como Firebase Remote Config.

¿Funciona con GitHub Actions?

Sí. Pasa los valores como variables de entorno en el workflow y genera el JSON de entorno dinámicamente antes del build: echo '{"API_BASE_URL":"${{ secrets.API_URL }}"}' > envs/prod.json.