Skip to content

7. Internationalisation

7.1. Exemple 21 : une application en deux langues

[Flutter] utilise des fichiers ARB, ou le JSON du paquet [easy_localization]. [Android] a sa propre méthode, intégrée au système : les textes de l'application sont des RESSOURCES. Le fichier [res/values/strings.xml] contient les textes de la langue par défaut, ici le français :

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/values/strings.xml - les textes de l'application, en FRANÇAIS
     (la langue par défaut). Leur traduction anglaise est dans
     res/values-en/strings.xml : Android choisit le fichier selon la
     langue de l'application (cf. exemple 21).
     ===================================================================== -->
<resources>
    <string name="app_name">Exemples Kotlin</string>

    <!-- exemple 21 -->
    <string name="ex21_titre">Exemple 21 - deux langues</string>
    <!-- %1$s : le premier paramètre, une chaîne -->
    <string name="ex21_bonjour">Bonjour %1$s !</string>
    <string name="ex21_aujourdhui">Nous sommes le %1$s.</string>
    <string name="ex21_prix">Une consultation coûte %1$s.</string>
    <string name="ex21_aucun_rdv">aucun rendez-vous</string>
    <!-- un texte au pluriel : une forme par "catégorie" de la langue ;
         %1$d : le premier paramètre, un entier -->
    <plurals name="ex21_rendez_vous">
        <item quantity="one">un rendez-vous</item>
        <item quantity="many">%1$d rendez-vous</item>
        <item quantity="other">%1$d rendez-vous</item>
    </plurals>
    <string name="ex21_ajouter">Ajouter un rendez-vous</string>
    <string name="ex21_retirer">En retirer un</string>
    <string name="ex21_calendrier">Ouvrir le calendrier</string>
    <string name="ex21_serveur_titre">Traduire une réponse du serveur</string>
    <string name="ex21_serveur_explication">Le serveur RdvMedecins ne renvoie que des clés. C\'est le client qui les traduit :</string>
    <string name="errors_login_taken">Ce login est déjà utilisé par un autre compte.</string>
    <string name="errors_too_many_attempts">Trop de tentatives infructueuses. Réessayez dans %1$s minute(s).</string>
</resources>
  • chaque texte a un NOM ; le code ne connaît que ce nom : [R.string.ex21_bonjour] ;
  • [%1$s] : le premier paramètre, une chaîne ; [%1$d] : un entier ;
  • [<plurals>] : un texte au pluriel, avec une forme par « catégorie » de la langue ([one], [many], [other]...) : [Android] connaît les règles de chaque langue ;
  • [C\'est] : l'apostrophe doit être précédée d'une barre oblique inverse.

Sa traduction, [res/values-en/strings.xml] : le même nom de fichier, dans un dossier suffixé par le code de la langue. [Android] choisit le fichier selon la langue de l'application :

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/values-en/strings.xml - les textes de l'application, en ANGLAIS.
     Mêmes noms que dans res/values/strings.xml ; un texte absent d'ici
     est pris dans la langue par défaut (le français).
     ===================================================================== -->
<resources>
    <string name="app_name">Kotlin examples</string>

    <string name="ex21_titre">Example 21 - two languages</string>
    <string name="ex21_bonjour">Hello %1$s!</string>
    <string name="ex21_aujourdhui">Today is %1$s.</string>
    <string name="ex21_prix">A consultation costs %1$s.</string>
    <string name="ex21_aucun_rdv">no appointment</string>
    <plurals name="ex21_rendez_vous">
        <item quantity="one">one appointment</item>
        <item quantity="other">%1$d appointments</item>
    </plurals>
    <string name="ex21_ajouter">Add an appointment</string>
    <string name="ex21_retirer">Remove one</string>
    <string name="ex21_calendrier">Open the calendar</string>
    <string name="ex21_serveur_titre">Translating a server response</string>
    <string name="ex21_serveur_explication">The RdvMedecins server only sends keys. The client translates them:</string>
    <string name="errors_login_taken">This login is already used by another account.</string>
    <string name="errors_too_many_attempts">Too many failed attempts. Try again in %1$s minute(s).</string>
</resources>

Les langues de l'application sont déclarées dans [res/xml/locales_config.xml], lui-même référencé par le manifeste ([android:localeConfig]) :

<?xml version="1.0" encoding="utf-8"?>
<!-- =====================================================================
     res/xml/locales_config.xml - les langues de l'application (exemple 21)
     ............................................................................
     Déclarées dans AndroidManifest.xml (android:localeConfig). Depuis
     Android 13, l'utilisateur peut aussi choisir la langue de CHAQUE
     application dans les réglages du téléphone : Paramètres > Système >
     Langues > Langues des applications.
     ===================================================================== -->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
    <locale android:name="fr" />
    <locale android:name="en" />
</locale-config>

Depuis [Android] 13, l'utilisateur peut alors choisir la langue de CHAQUE application dans les réglages du téléphone. Le code de l'exemple :

// =========================================================================
// ex21/Ex21Activity.kt - une application en deux langues
// -------------------------------------------------------------------------
// La méthode d'Android :
//   - les TEXTES de l'application sont des RESSOURCES : un fichier
//     res/values/strings.xml (la langue par défaut, ici le français) et un
//     fichier par traduction (res/values-en/strings.xml). Chaque texte a un
//     nom ; le code ne connaît que ce nom (R.string.ex21_bonjour), et
//     Android choisit le fichier selon la langue de l'application ;
//   - stringResource(R.string.ex21_bonjour, "Marie") : le texte, dont les
//     paramètres (%1$s) sont remplacés ; pluralStringResource : un texte au
//     pluriel, dont la forme suit les règles de la langue ;
//   - la langue de l'application : celle du téléphone... ou celle que
//     l'application choisit elle-même, AppCompatDelegate.setApplicationLocales.
//     Android RECRÉE alors l'activité, dans la nouvelle langue : les textes,
//     les dates, les montants, et les textes d'Android lui-même (le
//     calendrier, ses boutons).
// Le serveur RdvMedecins, lui, ne renvoie que des CLÉS ("ERRORS.LOGIN_TAKEN") :
// on les associe aux ressources par une table (clesServeur).
// L'activité est une AppCompatActivity : la classe d'activité qui sait
// appliquer la langue choisie, même sur les versions d'Android antérieures
// à la 13.
// =========================================================================
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)

package fr.stahe.exemples.ex21

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Card
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import fr.stahe.exemples.R
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import java.text.NumberFormat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Currency

val langues = listOf("fr", "en")

// ce que renverrait le serveur RdvMedecins (cf. chapitre sur la boîte noire)
data class ReponseServeur(val statusCode: Int, val cle: String, val params: Map<String, String>)

val reponsesServeur = listOf(
    ReponseServeur(409, "ERRORS.LOGIN_TAKEN", emptyMap()),
    ReponseServeur(429, "ERRORS.TOO_MANY_ATTEMPTS", mapOf("minutes" to "15")),
)

// la clé du serveur -> la ressource qui la traduit
val clesServeur = mapOf(
    "ERRORS.LOGIN_TAKEN" to R.string.errors_login_taken,
    "ERRORS.TOO_MANY_ATTEMPTS" to R.string.errors_too_many_attempts,
)

class Ex21Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageTraduite() } }
    }
}

// choisir la langue de l'application : Android la mémorise, et recrée l'activité
fun changerLangue(langue: String) {
    AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(langue))
}

@Composable
fun PageTraduite() {
    // rememberSaveable : le nombre survit à la recréation de l'activité
    var nombre by rememberSaveable { mutableIntStateOf(2) }
    var calendrierOuvert by rememberSaveable { mutableStateOf(false) }
    // la langue COURANTE : celle de la configuration de l'activité
    val locale = LocalConfiguration.current.locales[0]
    // des formateurs dans la langue courante
    val date = LocalDate.now().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale))
    val prix = NumberFormat.getCurrencyInstance(locale).apply { currency = Currency.getInstance("EUR") }.format(1234.5)

    PageExemple(
        titre = stringResource(R.string.ex21_titre),
        // les langues : des boutons "segmentés", dans la barre du haut
        actions = {
            SingleChoiceSegmentedButtonRow(Modifier.padding(end = 8.dp).width(120.dp)) {
                langues.forEachIndexed { i, l ->
                    SegmentedButton(
                        selected = locale.language == l,
                        onClick = { changerLangue(l) },
                        shape = SegmentedButtonDefaults.itemShape(i, langues.size),
                        icon = {},
                    ) { Text(l.uppercase()) }
                }
            }
        },
    ) {
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(12.dp)) {
                Text(stringResource(R.string.ex21_bonjour, "Marie"))
                Text(stringResource(R.string.ex21_aujourdhui, date))
                Text(stringResource(R.string.ex21_prix, prix))
                Spacer(Modifier.height(8.dp))
                // le pluriel : la catégorie "zero" n'existe ni en français ni en
                // anglais (0 est "one" en français, "other" en anglais) : on
                // traite 0 à part
                Text(
                    if (nombre == 0) stringResource(R.string.ex21_aucun_rdv) else pluralStringResource(R.plurals.ex21_rendez_vous, nombre, nombre),
                    fontWeight = FontWeight.Bold,
                )
                FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    TextButton(onClick = { nombre++ }) { Text(stringResource(R.string.ex21_ajouter)) }
                    TextButton(onClick = { nombre-- }, enabled = nombre > 0) { Text(stringResource(R.string.ex21_retirer)) }
                }
                // le calendrier de Material : ses textes suivent la langue de l'application
                OutlinedButton(onClick = { calendrierOuvert = true }) {
                    Icon(Icons.Default.DateRange, contentDescription = null)
                    Spacer(Modifier.width(8.dp))
                    Text(stringResource(R.string.ex21_calendrier))
                }
            }
        }
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(12.dp)) {
                Text(stringResource(R.string.ex21_serveur_titre), style = MaterialTheme.typography.titleSmall)
                Text(stringResource(R.string.ex21_serveur_explication))
                Spacer(Modifier.height(8.dp))
                for (r in reponsesServeur) {
                    Text("${r.statusCode} ${r.cle}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
                    // la ressource de la clé ; ses paramètres, dans l'ordre (*: "étale" le tableau)
                    val id = clesServeur[r.cle]
                    Text("→ " + if (id == null) r.cle else stringResource(id, *r.params.values.toTypedArray()))
                    Spacer(Modifier.height(6.dp))
                }
            }
        }
    }

    if (calendrierOuvert) {
        val etat = rememberDatePickerState()
        DatePickerDialog(
            onDismissRequest = { calendrierOuvert = false },
            // les textes OK / Annuler d'Android lui-même, dans la langue de l'application
            confirmButton = { TextButton(onClick = { calendrierOuvert = false }) { Text(stringResource(android.R.string.ok)) } },
            dismissButton = { TextButton(onClick = { calendrierOuvert = false }) { Text(stringResource(android.R.string.cancel)) } },
        ) {
            DatePicker(state = etat)
        }
    }
}
  • lignes 92-94 : le serveur RdvMedecins ne renvoie que des CLÉS (["ERRORS.LOGIN_TAKEN"]) : on les associe aux ressources par une table ;
  • ligne 97 : une [AppCompatActivity] : la classe d'activité qui sait appliquer la langue choisie, même avant [Android] 13 ;
  • lignes 106-107 : choisir la langue de l'APPLICATION (et non celle du téléphone) : [Android] la mémorise, et RECRÉE l'activité dans la nouvelle langue. Avant [Android] 13, c'est la bibliothèque [AppCompat] qui la mémorise (le service [AppLocalesMetadataHolderService] du manifeste, [autoStoreLocales]) ;
  • lignes 116-119 : la langue courante, celle de la configuration de l'activité, et des formateurs de dates et de montants dans cette langue ;
  • ligne 122 : [stringResource(R.string.ex21_titre)] : le texte dans la langue courante ;
  • lignes 10-11 : des paramètres, et un pluriel ([pluralStringResource]) : la catégorie [zero] n'existant ni en français ni en anglais, 0 est traité à part ;
  • lignes 170-171 : la traduction d'une clé du serveur, avec ses paramètres ([*] « étale » un tableau en une liste de paramètres) ;
  • lignes 180-184 : les textes d'[Android] lui-même ([android.R.string.ok]) et ceux du calendrier suivent aussi la langue de l'application.

Image

Image

Image

Le nombre de rendez-vous survit au changement de langue grâce à [rememberSaveable] : l'activité a pourtant été détruite et recréée.