feat(i18n): add redesign strings and translate Escuchar rename to 11 locales

This commit is contained in:
2026-07-29 16:35:30 +02:00
parent 2959941485
commit 3ec41bb31e
27 changed files with 2715 additions and 909 deletions
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'arb_test_helpers.dart';
import 'identical_value_allowlist.dart';
/// WU18 task 18.1b -- the anti-copy guard.
///
/// A key-parity test alone is NOT sufficient: it passes just as happily if
/// all 11 non-`es` locales simply copy the Spanish template value verbatim
/// for every key -- which would certify the exact bug it exists to catch
/// (see `reference/l10n-spanish-template-fallback`). This test collects
/// every value in a non-`es` locale that is byte-identical to the Spanish
/// template's value for the same key and fails unless that (locale, key)
/// pair is explicitly, individually allowlisted in
/// `identical_value_allowlist.dart`.
///
/// Scope note: `en` is deliberately excluded from this guard. Unlike the
/// other 11 locales -- which this change batch-translates for the first
/// time -- `en` and `es` have been hand-maintained together by every work
/// unit throughout this whole redesign (`en`/`es`-only new keys, never
/// left lagging), so `en` never carries the "silently fell back to the
/// template" risk this guard exists to catch.
const _auditedLocales = [
'ar',
'bn',
'de',
'fr',
'hi',
'id',
'it',
'ja',
'pt',
'ru',
'zh',
];
void main() {
test('every non-es value identical to the Spanish template is a '
'deliberately allowlisted exception, not an accidental untranslated '
'copy', () {
final es = readArb('es');
final unlisted = <String>[];
for (final locale in _auditedLocales) {
final arb = readArb(locale);
for (final key in realKeys(arb)) {
if (!es.containsKey(key))
continue; // arb_parity_test's job, not this one's
if (arb[key] == es[key] &&
!identicalValueAllowlist.contains((locale, key))) {
unlisted.add('$locale/$key = "${arb[key]}"');
}
}
}
expect(
unlisted,
isEmpty,
reason:
'Found value(s) identical to the Spanish template that are NOT '
'in identical_value_allowlist.dart -- this is very likely an '
'untranslated copy-paste, not a deliberate cognate/brand-name/'
'symbol exception. Translate it, or add a reviewed, individual '
'allowlist entry with a justification comment if it truly is '
'untranslatable:\n${unlisted.join('\n')}',
);
});
test(
'every allowlisted pair is a real, current collision (no stale entries)',
() {
final es = readArb('es');
final stale = <String>[];
for (final entry in identicalValueAllowlist) {
final (locale, key) = entry;
final arb = readArb(locale);
final stillCollides =
arb.containsKey(key) && es.containsKey(key) && arb[key] == es[key];
if (!stillCollides) {
stale.add('$locale/$key');
}
}
expect(
stale,
isEmpty,
reason:
'identical_value_allowlist.dart has entries that no longer '
'correspond to a real identical value -- remove them, the '
'allowlist must only contain genuine, current exceptions:\n'
'${stale.join('\n')}',
);
},
);
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'arb_test_helpers.dart';
/// WU18 task 18.1 / 18.2 -- ARB key-parity guard.
///
/// `l10n.yaml` sets `template-arb-file: app_es.arb`, so Spanish is the l10n
/// template: any key missing from a locale's ARB silently falls back to the
/// Spanish value at runtime (not English, which is the more common
/// assumption). This test guarantees all 13 locales carry the same key set,
/// so nothing can silently render in Spanish because a locale was never
/// given that key.
void main() {
test('all 13 ARB files declare an identical key set', () {
final arbs = {
for (final locale in supportedArbLocales) locale: readArb(locale),
};
final templateKeys = realKeys(arbs['es']!);
for (final locale in supportedArbLocales) {
if (locale == 'es') continue;
final keys = realKeys(arbs[locale]!);
final missing = templateKeys.difference(keys);
final extra = keys.difference(templateKeys);
expect(
missing,
isEmpty,
reason:
'app_$locale.arb is missing ${missing.length} key(s) present in '
'the es template (these silently fall back to Spanish at '
'runtime): $missing',
);
expect(
extra,
isEmpty,
reason:
'app_$locale.arb has ${extra.length} key(s) not present in the '
'es template: $extra',
);
}
});
test('navHome exists as a key in all 13 locales', () {
for (final locale in supportedArbLocales) {
final arb = readArb(locale);
expect(
realKeys(arb).contains('navHome'),
isTrue,
reason: 'app_$locale.arb is missing the navHome key',
);
}
});
}
+36
View File
@@ -0,0 +1,36 @@
import 'dart:convert';
import 'dart:io';
/// All 13 supported locale codes backing `lib/l10n/app_*.arb`.
///
/// `flutter test` always runs with the working directory set to the
/// package root, so `lib/l10n/...` below resolves correctly without any
/// extra path juggling.
const List<String> supportedArbLocales = [
'ar',
'bn',
'de',
'en',
'es',
'fr',
'hi',
'id',
'it',
'ja',
'pt',
'ru',
'zh',
];
/// Reads and decodes `lib/l10n/app_<locale>.arb` as a JSON map.
Map<String, dynamic> readArb(String locale) {
final file = File('lib/l10n/app_$locale.arb');
return json.decode(file.readAsStringSync()) as Map<String, dynamic>;
}
/// Real translation keys only -- excludes ICU metadata entries (`@key`)
/// and the `@@locale` marker, which are not themselves translatable
/// strings.
Set<String> realKeys(Map<String, dynamic> arb) {
return arb.keys.where((key) => !key.startsWith('@')).toSet();
}
+275
View File
@@ -0,0 +1,275 @@
/// WU18 anti-copy guard allowlist (task 18.1b).
///
/// A key-parity check alone is not enough to prove a locale was actually
/// translated: it passes just as happily if a locale simply copies the
/// Spanish (`app_es.arb`, the l10n template) value verbatim. `arb_anti_copy_test.dart`
/// scans every non-`es` locale for values that are byte-identical to the
/// Spanish template and fails unless each one is listed here.
///
/// Legitimately-identical values DO exist -- brand names, international
/// loanwords, pure symbols, and words that are simply spelled the same in
/// closely related Romance languages (Spanish/Portuguese/Italian/French all
/// share a huge amount of short, common vocabulary by descent, not by
/// accident). This file is the deliberate, reviewed escape hatch for those.
///
/// Every entry below was reviewed individually and is grouped only for
/// readability -- this file must NEVER be bulk-populated to silence the
/// test. If you find an unlisted collision, translate it; do not add it
/// here unless it is genuinely untranslatable.
library;
/// (locale, ARB key) pairs whose value is intentionally identical to the
/// Spanish template's value for that key.
const Set<(String locale, String key)> identicalValueAllowlist = {
// ---------------------------------------------------------------------
// Brand / product names -- never translated in any language.
// ---------------------------------------------------------------------
('ar', 'appTitle'), // "PluriWave"
('bn', 'appTitle'),
('de', 'appTitle'),
('fr', 'appTitle'),
('hi', 'appTitle'),
('id', 'appTitle'),
('it', 'appTitle'),
('ja', 'appTitle'),
('pt', 'appTitle'),
('ru', 'appTitle'),
('zh', 'appTitle'),
('ar', 'welcomeBullet2Title'), // "Android Auto" -- Google product name
('bn', 'welcomeBullet2Title'),
('de', 'welcomeBullet2Title'),
('fr', 'welcomeBullet2Title'),
('hi', 'welcomeBullet2Title'),
('id', 'welcomeBullet2Title'),
('it', 'welcomeBullet2Title'),
('ja', 'welcomeBullet2Title'),
('pt', 'welcomeBullet2Title'),
('ru', 'welcomeBullet2Title'),
('zh', 'welcomeBullet2Title'),
// ---------------------------------------------------------------------
// Pure symbols / placeholders -- no translatable text at all.
// ---------------------------------------------------------------------
('ar', 'dash'), // "—" (em dash)
('bn', 'dash'),
('de', 'dash'),
('fr', 'dash'),
('hi', 'dash'),
('id', 'dash'),
('it', 'dash'),
('ja', 'dash'),
('pt', 'dash'),
('ru', 'dash'),
('zh', 'dash'),
('ar', 'streamUrlHint'), // example URL shown as form hint text, not prose
('bn', 'streamUrlHint'),
('de', 'streamUrlHint'),
('fr', 'streamUrlHint'),
('hi', 'streamUrlHint'),
('id', 'streamUrlHint'),
('it', 'streamUrlHint'),
('ja', 'streamUrlHint'),
('pt', 'streamUrlHint'),
('ru', 'streamUrlHint'),
('zh', 'streamUrlHint'),
(
'ar',
'favoriteGroupsChipLabel',
), // "{groupName} · {count}" -- placeholders + separator only
('bn', 'favoriteGroupsChipLabel'),
('de', 'favoriteGroupsChipLabel'),
('fr', 'favoriteGroupsChipLabel'),
('hi', 'favoriteGroupsChipLabel'),
('id', 'favoriteGroupsChipLabel'),
('it', 'favoriteGroupsChipLabel'),
('ja', 'favoriteGroupsChipLabel'),
('pt', 'favoriteGroupsChipLabel'),
('ru', 'favoriteGroupsChipLabel'),
('zh', 'favoriteGroupsChipLabel'),
// ---------------------------------------------------------------------
// International music-genre labels -- treated as proper nouns and left
// untranslated by convention across virtually every music app/catalog
// (Spotify, Apple Music, YouTube Music, etc. all do the same).
// ---------------------------------------------------------------------
('de', 'genrePop'), ('de', 'genreRock'), ('de', 'genreJazz'),
('de', 'genreHipHop'), ('de', 'genreCountry'), ('de', 'genreMetal'),
('de', 'genreReggae'),
('fr', 'genrePop'), ('fr', 'genreRock'), ('fr', 'genreJazz'),
('fr', 'genreHipHop'), ('fr', 'genreCountry'), ('fr', 'genreMetal'),
('fr', 'genreReggae'),
('id', 'genrePop'), ('id', 'genreRock'), ('id', 'genreJazz'),
('id', 'genreHipHop'), ('id', 'genreCountry'), ('id', 'genreMetal'),
('id', 'genreReggae'),
('it', 'genrePop'), ('it', 'genreRock'), ('it', 'genreJazz'),
('it', 'genreHipHop'), ('it', 'genreCountry'), ('it', 'genreMetal'),
('it', 'genreReggae'),
('it', 'genreLatin'), // "Latina" -- also an es/it cognate
('pt', 'genrePop'), ('pt', 'genreRock'), ('pt', 'genreJazz'),
('pt', 'genreHipHop'), ('pt', 'genreCountry'), ('pt', 'genreMetal'),
('pt', 'genreReggae'),
('pt', 'genreLatin'), // "Latina" -- also an es/pt cognate
// Same genre names re-used verbatim as equalizer preset names.
('de', 'equalizerPresetRock'),
('de', 'equalizerPresetPop'),
('de', 'equalizerPresetJazz'),
('fr', 'equalizerPresetRock'),
('fr', 'equalizerPresetPop'),
('fr', 'equalizerPresetJazz'),
('id', 'equalizerPresetRock'),
('id', 'equalizerPresetPop'),
('id', 'equalizerPresetJazz'),
('it', 'equalizerPresetRock'),
('it', 'equalizerPresetPop'),
('it', 'equalizerPresetJazz'),
('pt', 'equalizerPresetRock'),
('pt', 'equalizerPresetPop'),
('pt', 'equalizerPresetJazz'),
// ---------------------------------------------------------------------
// "Preset"/"OK" -- international tech loanwords already established
// throughout this file (see `advancedEqDevicePresetLabel`, `actionOk`).
// ---------------------------------------------------------------------
('de', 'actionOk'), ('de', 'statusOk'),
('fr', 'actionOk'), ('fr', 'statusOk'),
('it', 'actionOk'), ('it', 'statusOk'),
('pt', 'actionOk'), ('pt', 'statusOk'),
('de', 'advancedEqDevicePresetLabel'), // "Preset: {presetName}"
('id', 'advancedEqDevicePresetLabel'),
('it', 'advancedEqDevicePresetLabel'),
('pt', 'advancedEqDevicePresetLabel'),
// ---------------------------------------------------------------------
// Duration / count unit abbreviations -- "min", "h", "s" are
// internationally understood abbreviations kept literal across many
// languages in this file already (not English left in place; German,
// French, Italian and Portuguese all natively abbreviate their own words
// for minute/hour/second the same way).
// ---------------------------------------------------------------------
('fr', 'durationHoursMinutesSeconds'), ('fr', 'durationMinutesSeconds'),
('fr', 'durationMinutesOnly'), ('fr', 'durationSecondsOnly'),
('fr', 'alarmSnoozeOptionLabel'), // "{minutes} min"
('it', 'durationHoursMinutesSeconds'), ('it', 'durationMinutesSeconds'),
('it', 'durationMinutesOnly'), ('it', 'durationSecondsOnly'),
('it', 'alarmSnoozeOptionLabel'),
('pt', 'durationHoursMinutesSeconds'), ('pt', 'durationMinutesSeconds'),
('pt', 'durationMinutesOnly'), ('pt', 'durationSecondsOnly'),
('pt', 'alarmSnoozeOptionLabel'),
(
'fr',
'stationsCount',
), // "{count} radios" -- "radios" is a real French cognate plural
(
'de',
'alarmFadeInLabel',
), // "Fade-in {seconds}s" -- "fade-in" is a standard audio-production term
('it', 'alarmFadeInLabel'),
('pt', 'alarmFadeInLabel'),
(
'de',
'settingsGroupAudioTitle',
), // "AUDIO" -- shared international/Latin-root term (WU18 new key)
('fr', 'settingsGroupAudioTitle'),
('id', 'settingsGroupAudioTitle'),
('it', 'settingsGroupAudioTitle'),
// ---------------------------------------------------------------------
// Short weekday abbreviations that happen to coincide across Romance
// languages even though the full weekday names differ (only the
// 3-letter abbreviation collides, e.g. lunes/lundi/lunedì -> "Lun").
// ---------------------------------------------------------------------
('fr', 'weekdayShortMonday'), ('fr', 'weekdayShortTuesday'),
('it', 'weekdayShortMonday'),
('it', 'weekdayShortTuesday'),
('it', 'weekdayShortSunday'),
('pt', 'weekdayShortSaturday'), ('pt', 'weekdayShortSunday'),
('fr', 'endField'), // "Fin" -- es/fr cognate ("end")
('fr', 'endLabel'), // "Fin"
// ---------------------------------------------------------------------
// Country / language endonyms and cognate proper nouns -- country and
// language names are frequently identical or near-identical across
// Romance languages (and beyond); several are also the country's own
// official native name.
// ---------------------------------------------------------------------
('id', 'countryArgentina'),
('it', 'countryArgentina'),
('pt', 'countryArgentina'),
('id', 'countryItaly'),
('it', 'countryItaly'), // "Italia" -- Italy's own endonym
('id', 'countryBrazil'),
(
'pt',
'countryBrazil',
), // "Brasil" -- Brazil's own official (Portuguese) name
('it', 'countryFrance'), // "Francia"
('pt', 'countryMexico'), // "México"
('pt', 'countryUk'), // "Reino Unido" -- identical es/pt calque
('it', 'languageSystemDefault'), // "Sistema"
('pt', 'languageSystemDefault'),
('pt', 'languageSectionTitle'), // "Idioma"
('pt', 'searchLanguageFilterLabel'), // "Idioma"
('pt', 'searchCountryFilterLabel'), // "País"
('pt', 'countryOptionalLabel'), // "País (opcional)"
// ---------------------------------------------------------------------
// Portuguese/Spanish close-cognate vocabulary. Portuguese and Spanish
// are both Iberian Romance languages and share an unusually large amount
// of short, common UI vocabulary verbatim -- these are correct,
// independently-chosen Portuguese words that happen to be spelled the
// same as their Spanish counterpart, not an untranslated leftover.
// ---------------------------------------------------------------------
('pt', 'navSearch'), // "Buscar"
('pt', 'navFavorites'), // "Favoritos"
('pt', 'favoritesTitle'), // "Favoritos"
('pt', 'cancelAction'), // "Cancelar"
('pt', 'cancelTimer'), // "Cancelar timer"
('pt', 'startTimer'), // "Iniciar timer"
('pt', 'hoursLabel'), // "Horas"
('pt', 'minutesLabel'), // "Minutos"
('pt', 'secondsLabel'), // "Segundos"
('pt', 'timeField'), // "Hora"
('pt', 'alarmInlineHourLabel'), // "Hora" (WU18 new key, task 18.3)
('it', 'alarmInlineMinuteLabel'), // "Minuto" (WU18 new key, task 18.3)
('pt', 'alarmInlineMinuteLabel'), // "Minuto" (WU18 new key, task 18.3)
('pt', 'countriesScreenTitle'), // "Países" (WU18 new key, task 18.3)
('pt', 'favoritesFilterAllLabel'), // "Todas" (WU18 new key, task 18.3)
('pt', 'seeAllAction'), // "Ver todas" (WU18 new key, task 18.3)
(
'pt',
'vacationUpcomingSectionTitle',
), // "PROGRAMADOS" (WU18 new key, task 18.3)
('pt', 'settingsSafeStatus'), // "Seguro"
('pt', 'preferredStationAutomaticFallback'), // "Fallback automático"
('pt', 'recordingsMaxSizeMbLabel'), // "Megabytes máximos"
('pt', 'favoriteGroupsTitle'), // "Listas de favoritos"
('pt', 'favoriteGroupsEdit'), // "Editar lista"
('pt', 'searchFiltersLabel'), // "Filtros"
('pt', 'detectAction'), // "Detectar"
('pt', 'alarmScreenTitle'), // "Despertar musical"
('pt', 'editAction'), // "Editar"
('pt', 'defaultAlarmName'), // "Despertador musical"
('pt', 'soundDigitalPulse'), // "Pulso digital"
('pt', 'pauseAction'), // "Pausar"
('pt', 'playbackStatusConnecting'), // "Conectando..."
('pt', 'playbackStatusPaused'), // "Pausado"
('pt', 'playbackStatusReconnecting'), // "Reconectando..."
('it', 'equalizerBandLabel'), // "Banda {band}"
('pt', 'equalizerBandLabel'), // "Banda {band}"
('pt', 'equalizerPresetFlat'), // "Plano"
('pt', 'equalizerPresetVoice'), // "Voz"
('pt', 'equalizerPresetCustom'), // "Personalizado"
('pt', 'customOption'), // "Personalizada"
('pt', 'indefiniteOption'), // "Indefinida"
('pt', 'invalidNumber'), // "Número inválido"
('pt', 'stopAction'), // "Parar"
('pt', 'eqDeviceEditTitle'), // "Editar dispositivo"
('pt', 'eqDeviceConnected'), // "Conectado"
('pt', 'localMusicSectionTitle'), // "Música local (Android Auto)"
(
'pt',
'recordingsLibraryStorageCaption',
), // WU18 new key (task 18.3) -- "usados"/"MB"/"de" all coincide
(
'pt',
'searchResultsCount',
), // WU18 new key (task 18.3) -- "resultado(s)" is an es/pt cognate
};