Internacionalización en Flutter: ejercicio resuelto con flutter_localizations y archivos ARB

Internacionalización en Flutter: ejercicio resuelto con flutter_localizations y archivos ARB

La internacionalización (i18n) en Flutter se basa en flutter_localizations + el generador de código gen-l10n. Los archivos ARB (Application Resource Bundle) son el formato estándar para las traducciones: un JSON anotado que el generador convierte en clases Dart tipadas.

Enunciado

Implementa una app multiidioma que:

  • Soporte inglés y español mediante archivos ARB.
  • Use AppLocalizations generado automáticamente con flutter gen-l10n.
  • Muestre traducciones con argumentos variables (nombre de usuario).
  • Use pluralización correcta (itemCount).
  • Muestre la fecha formateada según el locale activo.

Dependencias y configuración

pubspec.yaml

1
2
3
4
5
6
7
8
9
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.19.0

flutter:
  generate: true # activa gen-l10n

l10n.yaml (en la raíz del proyecto)

1
2
3
4
arb-dir: lib/l10n
template-arb-file: app_es.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations

lib/l10n/app_es.arb

 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
{
  "@@locale": "es",

  "appTitle": "Mi App Multiidioma",
  "@appTitle": {
    "description": "Título de la aplicación"
  },

  "greeting": "Hola, {name}",
  "@greeting": {
    "description": "Saludo personalizado",
    "placeholders": {
      "name": {
        "type": "String",
        "example": "Ana"
      }
    }
  },

  "itemCount": "{count, plural, =0{Sin elementos} =1{1 elemento} other{{count} elementos}}",
  "@itemCount": {
    "description": "Número de elementos con pluralización",
    "placeholders": {
      "count": {
        "type": "int"
      }
    }
  },

  "currentDate": "Fecha actual: {date}",
  "@currentDate": {
    "description": "Fecha formateada según el locale",
    "placeholders": {
      "date": {
        "type": "DateTime",
        "format": "yMMMMd",
        "isCustomDateFormat": "true"
      }
    }
  },

  "settingsTitle": "Configuración",
  "languageLabel": "Idioma",
  "addItem": "Añadir elemento",
  "removeItem": "Eliminar elemento",
  "welcomeMessage": "Bienvenido a la app de internacionalización"
}

lib/l10n/app_en.arb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "@@locale": "en",

  "appTitle": "My Multilanguage App",
  "greeting": "Hello, {name}",
  "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
  "currentDate": "Current date: {date}",
  "settingsTitle": "Settings",
  "languageLabel": "Language",
  "addItem": "Add item",
  "removeItem": "Remove item",
  "welcomeMessage": "Welcome to the internationalization app"
}

Después de crear los archivos ARB, ejecuta:

1
flutter gen-l10n

Esto genera lib/gen/app_localizations.dart (o dentro de .dart_tool/flutter_gen/).

Solución completa

  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
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
// El import exacto depende de tu configuración de l10n.yaml:
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter i18n Demo',

      // ── Configuración de localización ──────────────────────────────────
      localizationsDelegates: const [
        AppLocalizations.delegate,          // traducciones de la app
        GlobalMaterialLocalizations.delegate, // strings de Material (OK, Cancel...)
        GlobalWidgetsLocalizations.delegate,  // dirección del texto (LTR/RTL)
        GlobalCupertinoLocalizations.delegate, // strings de Cupertino
      ],
      supportedLocales: const [
        Locale('es'), // Español — idioma por defecto
        Locale('en'), // Inglés
      ],

      // locale: const Locale('es'), // Forzar un idioma (sin esto usa el del dispositivo)

      home: const LocalizationDemo(),
    );
  }
}

// ── Pantalla principal ─────────────────────────────────────────────────────────
class LocalizationDemo extends StatefulWidget {
  const LocalizationDemo({super.key});
  @override
  State<LocalizationDemo> createState() => _LocalizationDemoState();
}

class _LocalizationDemoState extends State<LocalizationDemo> {
  int _itemCount = 0;
  final String _userName = 'Ana';

  @override
  Widget build(BuildContext context) {
    // Acceso a las traducciones: nunca null dentro de un MaterialApp configurado
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // ── Saludo con argumento ─────────────────────────────────────
            Card(
              child: ListTile(
                leading: const Icon(Icons.person),
                title: Text(l10n.greeting(_userName)),
                subtitle: Text(l10n.welcomeMessage),
              ),
            ),

            const SizedBox(height: 16),

            // ── Fecha formateada ─────────────────────────────────────────
            Card(
              child: ListTile(
                leading: const Icon(Icons.calendar_today),
                title: Text(l10n.currentDate(DateTime.now())),
              ),
            ),

            const SizedBox(height: 16),

            // ── Pluralización ────────────────────────────────────────────
            Card(
              child: Column(
                children: [
                  ListTile(
                    leading: const Icon(Icons.list),
                    title: Text(l10n.itemCount(_itemCount)),
                    trailing: Text(
                      '$_itemCount',
                      style: Theme.of(context).textTheme.headlineMedium,
                    ),
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      TextButton.icon(
                        onPressed: () => setState(() => _itemCount++),
                        icon: const Icon(Icons.add),
                        label: Text(l10n.addItem),
                      ),
                      TextButton.icon(
                        onPressed: _itemCount > 0
                            ? () => setState(() => _itemCount--)
                            : null,
                        icon: const Icon(Icons.remove),
                        label: Text(l10n.removeItem),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                ],
              ),
            ),

            const Spacer(),

            // ── Info de locale activo ────────────────────────────────────
            _LocaleInfo(),
          ],
        ),
      ),
    );
  }
}

// ── Info del locale activo ─────────────────────────────────────────────────────
class _LocaleInfo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.blue.shade50,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.blue.shade200),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Locale activo: ${locale.toLanguageTag()}',
              style: const TextStyle(fontWeight: FontWeight.bold)),
          Text('Código de idioma: ${locale.languageCode}'),
          if (locale.countryCode != null)
            Text('País: ${locale.countryCode}'),
          Text(
            'Cambia el idioma del dispositivo para ver el cambio automático.',
            style: const TextStyle(fontSize: 12, color: Colors.grey),
          ),
        ],
      ),
    );
  }
}

Estructura de archivos ARB

CampoUso
@@localeDeclara el idioma del archivo ("es", "en")
"key": "value"Traducción simple
"@key"Metadatos de la clave (description, placeholders)
{param}Argumento de tipo String posicional
{count, plural, ...}Pluralización según ICU MessageFormat
{date} con "type": "DateTime"Fecha formateada con intl según el locale

Errores frecuentes

  • Olvidar flutter: generate: true en pubspec.yaml: sin esta línea, flutter gen-l10n no genera los archivos Dart y el import de AppLocalizations no existe.
  • Usar AppLocalizations.of(context) sin el !: en un MaterialApp correctamente configurado nunca devuelve null dentro del árbol de widgets. Usa ! o comprueba null solo si hay pantallas fuera del árbol de localización.
  • No añadir GlobalMaterialLocalizations.delegate: sin este delegate, los strings nativos de Material (botón “OK”, “Cancelar”, el picker de fechas) no se traducen aunque tu contenido sí lo esté.
  • ARB con sintaxis incorrecta de plurales: el formato ICU es {count, plural, =0{...} =1{...} other{...}}. El other es obligatorio — si lo omites, gen-l10n falla con un error críptico.

Aplicación práctica

Cualquier app publicada en múltiples mercados necesita i18n: apps de productividad, e-commerce y herramientas SaaS. La clave es implementarlo desde el principio — retrofit de i18n en una app grande es costoso.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Debo usar gen-l10n o el paquete intl_utils?

gen-l10n es la solución oficial del equipo de Flutter y no requiere dependencias extra. intl_utils es una alternativa popular con integración con VS Code y Android Studio, pero introduce una dependencia de desarrollo adicional. Para proyectos nuevos, usa gen-l10n.

¿Cómo funciona la pluralización en idiomas con más de dos formas?

ICU MessageFormat soporta las categorías zero, one, two, few, many, other. El locale determina cuántas formas usa un idioma: el inglés solo usa one/other, el polaco usa one/few/many/other. La librería intl gestiona automáticamente la selección según el locale.

¿Los archivos ARB se pueden compartir con otras plataformas?

Sí. ARB es también el formato estándar de Flutter Web, macOS y Desktop. Algunos equipos reutilizan los mismos archivos ARB en Android (con android-arb-plugin) y en proyectos iOS/macOS de forma semiautomática.