Skip to content

7. Internationalisation

7.1. Exemple 21 : une application en deux langues

Une application en deux langues a trois sortes de textes à traduire :

  • les textes de l'application : ses libellés, ses messages. On les range dans des dictionnaires, un par langue ;
  • les dates et les montants : le paquet [intl] (cf. exemple 09), dans la langue courante ;
  • les textes de [Flutter] lui-même : les boutons de ses fenêtres, les jours et les mois de son calendrier. Le paquet [flutter_localizations] les fournit.

7.1.1. Les dictionnaires

{
  "APP": {
    "TITRE": "Exemple 21 - deux langues",
    "LANGUE": "Langue"
  },
  "ACCUEIL": {
    "BONJOUR": "Bonjour {{nom}} !",
    "AUJOURDHUI": "Nous sommes le {{date}}.",
    "PRIX": "Une consultation coûte {{prix}}.",
    "RENDEZ_VOUS_zero": "aucun rendez-vous",
    "RENDEZ_VOUS_one": "un rendez-vous",
    "RENDEZ_VOUS_other": "{{count}} rendez-vous",
    "AJOUTER": "Ajouter un rendez-vous",
    "RETIRER": "En retirer un",
    "CALENDRIER": "Ouvrir le calendrier"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s)."
  },
  "SERVEUR": {
    "TITRE": "Traduire une réponse du serveur",
    "EXPLICATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
  }
}

Chaque texte a une clé (« ACCUEIL.BONJOUR »). Les paramètres s'écrivent [{{nom}}] ; un texte au pluriel a plusieurs clés, [_zero], [_one], [_other]. Ce sont les conventions de la bibliothèque [JavaScript] [i18next], utilisée par les clients [React] et [Vue.js] de [RdvMedecins] : l'étude de cas reprendra leurs dictionnaires tels quels. Le dictionnaire anglais a les mêmes clés :

{
  "APP": {
    "TITRE": "Example 21 - two languages",
    "LANGUE": "Language"
  },
  "ACCUEIL": {
    "BONJOUR": "Hello {{nom}}!",
    "AUJOURDHUI": "Today is {{date}}.",
    "PRIX": "A consultation costs {{prix}}.",
    "RENDEZ_VOUS_zero": "no appointment",
    "RENDEZ_VOUS_one": "one appointment",
    "RENDEZ_VOUS_other": "{{count}} appointments",
    "AJOUTER": "Add an appointment",
    "RETIRER": "Remove one",
    "CALENDRIER": "Open the calendar"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "This login is already used by another account.",
    "TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s)."
  },
  "SERVEUR": {
    "TITRE": "Translating a server response",
    "EXPLICATION": "The RdvMedecins server only sends keys. The client translates them:"
  }
}

7.1.2. Les traductions

// =========================================================================
// lib/ex21_i18n/traductions.dart - des dictionnaires, une langue courante
// -------------------------------------------------------------------------
// Chaque langue a son DICTIONNAIRE, un fichier JSON livré avec
// l'application (assets/i18n/ex21_fr.json, ex21_en.json), où chaque texte
// a une CLÉ ("ACCUEIL.BONJOUR") :
//   - t('ACCUEIL.BONJOUR', {'nom': 'Marie'}) : le texte de la langue
//     COURANTE, dont les paramètres {{nom}} sont remplacés ;
//   - t('ACCUEIL.RENDEZ_VOUS', {'count': n}) : un texte au pluriel ; on
//     choisit la clé RENDEZ_VOUS_zero, _one ou _other selon n et les règles
//     de la langue (en français, 0 et 1 sont au singulier ; en anglais,
//     seulement 1) ;
//   - changerLangue('en') : charge l'autre dictionnaire, et prévient les
//     écrans (un ChangeNotifier), qui se retraduisent.
// Ce sont les conventions de la bibliothèque JavaScript i18next : les
// dictionnaires sont ceux des clients Vue.js, Angular et React de
// RdvMedecins... et le serveur, qui ne renvoie que des CLÉS
// ("ERRORS.LOGIN_TAKEN"), les utilise aussi.
// =========================================================================

import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';

const langues = ['fr', 'en'];

class Traductions extends ChangeNotifier {
  final String _prefixe; // les dictionnaires : assets/i18n/<prefixe>_<langue>.json
  final SharedPreferences? _stockage;
  String _langue = 'fr';
  Map<String, Object?> _dictionnaire = {};

  Traductions(this._prefixe, [this._stockage]);

  String get langue => _langue;

  // la langue de départ : celle mémorisée sur l'appareil, sinon celle du
  // téléphone (si elle est connue), sinon le français
  Future<void> demarrer() async {
    final memorisee = _stockage?.getString('$_prefixe.langue');
    final appareil = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
    await changerLangue(memorisee ?? (langues.contains(appareil) ? appareil : 'fr'));
  }

  Future<void> changerLangue(String langue) async {
    // rootBundle : les fichiers livrés avec l'application (section assets du pubspec)
    final json = await rootBundle.loadString('assets/i18n/${_prefixe}_$langue.json');
    _dictionnaire = jsonDecode(json) as Map<String, Object?>;
    _langue = langue;
    await _stockage?.setString('$_prefixe.langue', langue);
    notifyListeners();
  }

  // "ACCUEIL.BONJOUR" -> dictionnaire['ACCUEIL']['BONJOUR'] ; null si absente
  String? _chercher(String cle) {
    Object? noeud = _dictionnaire;
    for (final partie in cle.split('.')) {
      noeud = noeud is Map<String, Object?> ? noeud[partie] : null;
    }
    return noeud is String ? noeud : null;
  }

  String t(String cle, [Map<String, Object?> params = const {}]) {
    String? texte;
    final count = params['count'];
    if (count is int) {
      // Intl.pluralLogic : la forme du pluriel, selon les règles de la langue
      texte = Intl.pluralLogic(
        count,
        locale: _langue,
        zero: _chercher('${cle}_zero'),
        one: _chercher('${cle}_one'),
        other: _chercher('${cle}_other'),
      );
    }
    texte ??= _chercher(cle);
    // une clé inconnue s'affiche telle quelle : on voit tout de suite ce qui manque
    if (texte == null) return cle;
    // {{nom}} -> la valeur du paramètre nom
    return texte.replaceAllMapped(RegExp(r'\{\{(\w+)\}\}'), (m) => '${params[m[1]] ?? m[0]}');
  }
}
  • lignes 30-34 : les traductions : un [ChangeNotifier] (les écrans seront reconstruits quand la langue change), qui garde la langue courante et son dictionnaire ;
  • lignes 42-45 : la langue de départ : celle mémorisée sur l'appareil, sinon celle du téléphone (si elle est connue), sinon le français ;
  • lignes 48-54 : changer de langue : charger le dictionnaire, un fichier JSON livré avec l'application ([rootBundle.loadString] lit un fichier de la section [assets] du [pubspec.yaml]), le décoder ([jsonDecode]), mémoriser le choix, prévenir les écrans ;
  • lignes 58-63 : chercher une clé « ACCUEIL.BONJOUR » dans le dictionnaire : on descend, partie par partie, dans les objets JSON imbriqués. [noeud is Map<String, Object?> ? ... : null] : un test de type, qui « promeut » la variable dans la branche où il est vrai ;
  • lignes 66-83 : la traduction. Pour un pluriel (un paramètre [count] entier), [Intl.pluralLogic] choisit la forme selon les règles de la langue : en français, 0 et 1 sont au singulier ; en anglais, seulement 1. Une clé inconnue s'affiche telle quelle : on voit tout de suite ce qui manque. Enfin, chaque [{{nom}}] est remplacé par la valeur du paramètre.

Pourquoi pas l'outil officiel de [Flutter] ? [Flutter] propose un générateur ([flutter gen-l10n]) qui fabrique, à partir de fichiers [.arb], une classe avec une méthode par texte ([AppLocalizations.of(context).bonjour('Marie')]) : les clés sont vérifiées à la compilation. C'est la meilleure solution pour une application dont tous les textes sont connus d'avance. Mais notre serveur renvoie des clés (« ERRORS.LOGIN_TAKEN ») qu'il faut traduire à l'exécution : un dictionnaire, qu'on interroge par la clé, s'y prête beaucoup mieux, et nous permet de partager les dictionnaires des autres clients.

7.1.3. La page traduite

// =========================================================================
// lib/ex21_i18n/main.dart - une application en deux langues
// -------------------------------------------------------------------------
//   - les TEXTES de l'application : les dictionnaires (traductions.dart),
//     fournis à tous les écrans par provider (cf. exemple 20) ;
//   - les dates et les montants : le paquet intl, dans la langue courante
//     (cf. exemple 09) ;
//   - les textes de Flutter LUI-MÊME (le calendrier, ses boutons...) : les
//     "délégués" de localisation du paquet flutter_localizations, qui
//     suivent la propriété locale de MaterialApp.
// Changer de langue retraduit tout l'écran, sans redémarrer.
// =========================================================================

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'traductions.dart';

// ce que renverrait le serveur RdvMedecins (cf. chapitre sur la boîte noire)
const reponsesServeur = [
  (statusCode: 409, cle: 'ERRORS.LOGIN_TAKEN', params: <String, String>{}),
  (statusCode: 429, cle: 'ERRORS.TOO_MANY_ATTEMPTS', params: {'minutes': '15'}),
];

// context.t('CLE') : un raccourci, par une extension de BuildContext
extension Traduire on BuildContext {
  String t(String cle, [Map<String, Object?> params = const {}]) => watch<Traductions>().t(cle, params);
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initializeDateFormatting();
  final traductions = Traductions('ex21', await SharedPreferences.getInstance());
  await traductions.demarrer();
  runApp(ChangeNotifierProvider.value(value: traductions, child: const Application()));
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      // onGenerateTitle : le titre de l'application, traduit
      onGenerateTitle: (context) => context.t('APP.TITRE'),
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      locale: Locale(context.watch<Traductions>().langue),
      supportedLocales: [for (final l in langues) Locale(l)],
      localizationsDelegates: GlobalMaterialLocalizations.delegates,
      home: const PageTraduite(),
    );
  }
}

class PageTraduite extends StatefulWidget {
  const PageTraduite({super.key});

  @override
  State<PageTraduite> createState() => _PageTraduiteState();
}

class _PageTraduiteState extends State<PageTraduite> {
  int _nombre = 2;
  final _aujourdhui = DateTime.now();

  @override
  Widget build(BuildContext context) {
    final traductions = context.watch<Traductions>();
    final langue = traductions.langue;
    // des formateurs dans la langue COURANTE
    final date = DateFormat.yMMMMEEEEd(langue).format(_aujourdhui);
    final prix = NumberFormat.currency(locale: langue, name: 'EUR', symbol: '€').format(1234.5);

    return Scaffold(
      appBar: AppBar(
        title: Text(context.t('APP.TITRE')),
        actions: [
          // les langues : des boutons "segmentés"
          Padding(
            padding: const EdgeInsets.only(right: 8),
            child: SegmentedButton<String>(
              showSelectedIcon: false,
              segments: [for (final l in langues) ButtonSegment(value: l, label: Text(l.toUpperCase()))],
              selected: {langue},
              onSelectionChanged: (choix) => traductions.changerLangue(choix.first),
            ),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(context.t('ACCUEIL.BONJOUR', {'nom': 'Marie'})),
                  Text(context.t('ACCUEIL.AUJOURDHUI', {'date': date})),
                  Text(context.t('ACCUEIL.PRIX', {'prix': prix})),
                  const SizedBox(height: 8),
                  // count : choisit la forme du pluriel, et remplace {{count}}
                  Text(
                    context.t('ACCUEIL.RENDEZ_VOUS', {'count': _nombre}),
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  Wrap(
                    spacing: 8,
                    children: [
                      TextButton(onPressed: () => setState(() => _nombre++), child: Text(context.t('ACCUEIL.AJOUTER'))),
                      TextButton(
                        onPressed: _nombre == 0 ? null : () => setState(() => _nombre--),
                        child: Text(context.t('ACCUEIL.RETIRER')),
                      ),
                    ],
                  ),
                  // le calendrier de Flutter : ses textes suivent la langue de MaterialApp
                  OutlinedButton.icon(
                    icon: const Icon(Icons.calendar_month),
                    label: Text(context.t('ACCUEIL.CALENDRIER')),
                    onPressed: () =>
                        showDatePicker(context: context, firstDate: DateTime(2026), lastDate: DateTime(2027, 12, 31)),
                  ),
                ],
              ),
            ),
          ),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(context.t('SERVEUR.TITRE'), style: Theme.of(context).textTheme.titleSmall),
                  Text(context.t('SERVEUR.EXPLICATION')),
                  const SizedBox(height: 8),
                  // la clé du serveur EST une clé du dictionnaire ; params fournit ses paramètres
                  for (final r in reponsesServeur) ...[
                    Text('${r.statusCode} ${r.cle}', style: const TextStyle(fontFamily: 'RobotoMono', fontSize: 12)),
                    Text('→ ${context.t(r.cle, r.params)}'),
                    const SizedBox(height: 6),
                  ],
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}
  • lignes 30-31 : une extension de [BuildContext] (cf. exemple 09) : [context.t('CLE')] lit les traductions fournies plus haut, s'abonne à leurs changements, et traduit ;
  • lignes 36-38 : le démarrage : les noms des jours et des mois de toutes les langues, puis les traductions ;
  • ligne 39 : [ChangeNotifierProvider.value] fournit un objet DÉJÀ créé (par [main]) ;
  • ligne 50 : le titre de l'application, traduit ;
  • lignes 52-54 : la langue de [Flutter] lui-même suit celle de l'application ;
  • lignes 76-77 : la date et le prix, mis en forme dans la langue courante, puis passés en paramètres aux textes ;
  • lignes 86-90 : le choix de la langue, dans la barre du haut ;
  • ligne 110 : [count] choisit la forme du pluriel, et remplace [{{count}}] ;
  • lignes 124-128 : le calendrier de [Flutter] : ses textes suivent la langue de [MaterialApp] ;
  • lignes 144-146 : ce que renverrait le serveur [RdvMedecins] : une clé, et ses paramètres. La clé du serveur EST une clé du dictionnaire.

En français (à gauche) ; en anglais, après un ajout (au milieu) ; le calendrier, en anglais (à droite) :

Changer de langue retraduit tout l'écran, sans redémarrer ni rien demander au serveur. La langue choisie est mémorisée sur l'appareil.