Unverified Commit 75960f35 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Cleanup in localizations code (#20018)

The following changes are made by this PR:

 * Translation bundles now implement MaterialLocalizations directly,
   and are public so that they can be directly extended.

 * The list of supported languages is now a generated constant.

 * The icuShortTimePattern/TimeOfDayFormat values are now pre-parsed.

 * Various other changes for consistency with the style guide and the
   rest of the codebase, e.g. the class names don't use `_`, the
   `path` library is imported as such, more dartdocs, fewer `//
   ignore`s, validation using exceptions.

This reduces our technical debt benchmark.
parent a96fb449
......@@ -6,7 +6,8 @@
/// package for the subset of locales supported by the flutter_localizations
/// package.
///
/// The extracted data is written into packages/flutter_localizations/lib/src/l10n/date_localizations.dart.
/// The extracted data is written into:
/// packages/flutter_localizations/lib/src/l10n/date_localizations.dart
///
/// ## Usage
///
......@@ -89,7 +90,7 @@ Future<Null> main(List<String> rawArgs) async {
});
buffer.writeln('};');
// Note: code that uses datePatterns expects it to contain values of type
// Code that uses datePatterns expects it to contain values of type
// Map<String, String> not Map<String, dynamic>.
buffer.writeln('const Map<String, Map<String, String>> datePatterns = const <String, Map<String, String>> {');
patternFiles.forEach((String locale, File data) {
......
This diff is collapsed.
......@@ -2,15 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart' as argslib;
import 'package:meta/meta.dart';
void exitWithError(String errorMessage) {
if (errorMessage == null)
return;
stderr.writeln('Fatal Error: $errorMessage');
assert(errorMessage != null);
stderr.writeln('fatal: $errorMessage');
exit(1);
}
......@@ -25,6 +26,13 @@ void checkCwdIsRepoRoot(String commandName) {
}
}
String camelCase(String locale) {
return locale
.split('_')
.map((String part) => part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase())
.join('');
}
GeneratorOptions parseArgs(List<String> rawArgs) {
final argslib.ArgParser argParser = new argslib.ArgParser()
..addFlag(
......@@ -45,3 +53,90 @@ class GeneratorOptions {
final bool writeToFile;
}
const String registry = 'https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry';
// See also //master/tools/gen_locale.dart in the engine repo.
Map<String, List<String>> _parseSection(String section) {
final Map<String, List<String>> result = <String, List<String>>{};
List<String> lastHeading;
for (String line in section.split('\n')) {
if (line == '')
continue;
if (line.startsWith(' ')) {
lastHeading[lastHeading.length - 1] = '${lastHeading.last}${line.substring(1)}';
continue;
}
final int colon = line.indexOf(':');
if (colon <= 0)
throw 'not sure how to deal with "$line"';
final String name = line.substring(0, colon);
final String value = line.substring(colon + 2);
lastHeading = result.putIfAbsent(name, () => <String>[]);
result[name].add(value);
}
return result;
}
final Map<String, String> _languages = <String, String>{};
final Map<String, String> _regions = <String, String>{};
final Map<String, String> _scripts = <String, String>{};
const String kProvincePrefix = ', Province of ';
const String kParentheticalPrefix = ' (';
/// Prepares the data for the [describeLocale] method below.
///
/// The data is obtained from the official IANA registry.
Future<void> precacheLanguageAndRegionTags() async {
final HttpClient client = new HttpClient();
final HttpClientRequest request = await client.getUrl(Uri.parse(registry));
final HttpClientResponse response = await request.close();
final String body = (await response.transform(utf8.decoder).toList()).join('');
client.close(force: true);
final List<Map<String, List<String>>> sections = body.split('%%').skip(1).map<Map<String, List<String>>>(_parseSection).toList();
for (Map<String, List<String>> section in sections) {
assert(section.containsKey('Type'), section.toString());
final String type = section['Type'].single;
if (type == 'language' || type == 'region' || type == 'script') {
assert(section.containsKey('Subtag') && section.containsKey('Description'), section.toString());
final String subtag = section['Subtag'].single;
String description = section['Description'].join(' ');
if (description.startsWith('United '))
description = 'the $description';
if (description.contains(kParentheticalPrefix))
description = description.substring(0, description.indexOf(kParentheticalPrefix));
if (description.contains(kProvincePrefix))
description = description.substring(0, description.indexOf(kProvincePrefix));
if (description.endsWith(' Republic'))
description = 'the $description';
switch (type) {
case 'language':
_languages[subtag] = description;
break;
case 'region':
_regions[subtag] = description;
break;
case 'script':
_scripts[subtag] = description;
break;
}
}
}
}
String describeLocale(String tag) {
final List<String> subtags = tag.split('_');
assert(subtags.isNotEmpty);
assert(_languages.containsKey(subtags[0]));
final String language = _languages[subtags[0]];
if (subtags.length >= 2) {
final String region = _regions[subtags[1]];
final String script = _scripts[subtags[1]];
assert(region != null || script != null);
if (region != null)
return '$language, as used in $region';
if (script != null)
return '$language, using the $script script';
}
return '$language';
}
\ No newline at end of file
......@@ -5,6 +5,18 @@
import 'dart:convert' show json;
import 'dart:io';
// The first suffix in kPluralSuffixes must be "Other". "Other" is special
// because it's the only one that is required.
const List<String> kPluralSuffixes = <String>['Other', 'Zero', 'One', 'Two', 'Few', 'Many'];
final RegExp kPluralRegexp = new RegExp(r'(\w*)(' + kPluralSuffixes.skip(1).join(r'|') + r')$');
class ValidationError implements Exception {
ValidationError(this. message);
final String message;
@override
String toString() => message;
}
/// Sanity checking of the @foo metadata in the English translations,
/// material_en.arb.
///
......@@ -14,13 +26,13 @@ import 'dart:io';
/// - Each @foo resource must have a Map value with a String valued
/// description entry.
///
/// Returns an error message upon failure, null on success.
String validateEnglishLocalizations(File file) {
/// Throws an exception upon failure.
void validateEnglishLocalizations(File file) {
final StringBuffer errorMessages = new StringBuffer();
if (!file.existsSync()) {
errorMessages.writeln('English localizations do not exist: $file');
return errorMessages.toString();
throw new ValidationError(errorMessages.toString());
}
final Map<String, dynamic> bundle = json.decode(file.readAsStringSync());
......@@ -36,7 +48,7 @@ String validateEnglishLocalizations(File file) {
final int suffixIndex = resourceId.indexOf(suffix);
return suffixIndex != -1 && bundle['@${resourceId.substring(0, suffixIndex)}'] != null;
}
if (<String>['Zero', 'One', 'Two', 'Few', 'Many', 'Other'].any(checkPluralResource))
if (kPluralSuffixes.any(checkPluralResource))
continue;
errorMessages.writeln('A value was not specified for @$resourceId');
......@@ -70,7 +82,8 @@ String validateEnglishLocalizations(File file) {
}
}
return errorMessages.isEmpty ? null : errorMessages.toString();
if (errorMessages.isNotEmpty)
throw new ValidationError(errorMessages.toString());
}
/// Enforces the following invariants in our localizations:
......@@ -81,8 +94,8 @@ String validateEnglishLocalizations(File file) {
/// Uses "en" localizations as the canonical source of locale keys that other
/// locales are compared against.
///
/// If validation fails, return an error message, otherwise return null.
String validateLocalizations(
/// If validation fails, throws an exception.
void validateLocalizations(
Map<String, Map<String, String>> localeToResources,
Map<String, Map<String, dynamic>> localeToAttributes,
) {
......@@ -99,12 +112,9 @@ String validateLocalizations(
// Many languages require only a subset of these variations, so we do not
// require them so long as the "Other" variation exists.
bool isPluralVariation(String key) {
final RegExp pluralRegexp = new RegExp(r'(\w*)(Zero|One|Two|Few|Many)$');
final Match pluralMatch = pluralRegexp.firstMatch(key);
final Match pluralMatch = kPluralRegexp.firstMatch(key);
if (pluralMatch == null)
return false;
final String prefix = pluralMatch[1];
return resources.containsKey('${prefix}Other');
}
......@@ -151,7 +161,6 @@ String validateLocalizations(
..writeln(' "notUsed": "Sindhi time format does not use a.m. indicator"')
..writeln('}');
}
return errorMessages.toString();
throw new ValidationError(errorMessages.toString());
}
return null;
}
......@@ -134,7 +134,8 @@ Iterable<String> debugWordWrap(String message, int width, { String wrapIndent =
if ((index - startForLengthCalculations > width) || (index == message.length)) {
// we are over the width line, so break
if ((index - startForLengthCalculations <= width) || (lastWordEnd == null)) {
// we should use this point, before either it doesn't actually go over the end (last line), or it does, but there was no earlier break point
// we should use this point, because either it doesn't actually go over the
// end (last line), or it does, but there was no earlier break point
lastWordEnd = index;
}
if (addPrefix) {
......
......@@ -5,5 +5,6 @@
/// Localizations for the Flutter library
library flutter_localizations;
export 'src/material_localizations.dart' show GlobalMaterialLocalizations;
export 'src/widgets_localizations.dart' show GlobalWidgetsLocalizations;
export 'src/l10n/localizations.dart';
export 'src/material_localizations.dart';
export 'src/widgets_localizations.dart';
......@@ -68,7 +68,8 @@ contain translations for the same set of resource IDs as
For each resource ID defined for English in material_en.arb, there is
an additional resource with an '@' prefix. These '@' resources are not
used by the material library at run time, they just exist to inform
translators about how the value will be used.
translators about how the value will be used, and to inform the code
generator about what code to write.
```dart
"cancelButtonLabel": "CANCEL",
......@@ -130,9 +131,11 @@ help define an app's text theme and time picker layout respectively.
The value of `timeOfDayFormat` defines how a time picker displayed by
[showTimePicker()](https://docs.flutter.io/flutter/material/showTimePicker.html)
formats and lays out its time controls. The value of `timeOfDayFormat` must be
a string that matches one of the formats defined by
https://docs.flutter.io/flutter/material/TimeOfDayFormat-class.html.
formats and lays out its time controls. The value of `timeOfDayFormat`
must be a string that matches one of the formats defined by
<https://docs.flutter.io/flutter/material/TimeOfDayFormat-class.html>.
It is converted to an enum value because the `material_en.arb` file
has this value labeled as `"x-flutter-type": "icuShortTimePattern"`.
The value of `scriptCategory` is based on the
[Language categories reference](https://material.io/go/design-typography#typography-language-categories-reference)
......
......@@ -7668,9 +7668,9 @@ const Map<String, dynamic> dateSymbols = <String, dynamic>{
],
'AMPMS': <dynamic>[r'''AM''', r'''PM'''],
'DATEFORMATS': <dynamic>[
r'''EEEE, MMMM d, y''',
r'''MMMM d, y''',
r'''MMM d, y''',
r'''EEEE، d MMMM، y''',
r'''d MMMM، y''',
r'''d MMM، y''',
r'''d/M/yy'''
],
'TIMEFORMATS': <dynamic>[
......@@ -9994,7 +9994,7 @@ const Map<String, Map<String, String>> datePatterns =
'MMMd': r'''d MMM''',
'MMMEd': r'''EEE، d MMM''',
'MMMM': r'''LLLL''',
'MMMMd': r'''MMMM d''',
'MMMMd': r'''d MMMM''',
'MMMMEEEEd': r'''EEEE، d MMMM''',
'QQQ': r'''QQQ''',
'QQQQ': r'''QQQQ''',
......@@ -10006,8 +10006,8 @@ const Map<String, Map<String, String>> datePatterns =
'yMMMd': r'''d MMM، y''',
'yMMMEd': r'''EEE، d MMM، y''',
'yMMMM': r'''MMMM y''',
'yMMMMd': r'''MMMM d, y''',
'yMMMMEEEEd': r'''EEEE, MMMM d, y''',
'yMMMMd': r'''d MMMM، y''',
'yMMMMEEEEd': r'''EEEE، d MMMM، y''',
'yQQQ': r'''QQQ y''',
'yQQQQ': r'''QQQQ y''',
'H': r'''HH''',
......
......@@ -6,7 +6,8 @@
"timeOfDayFormat": "h:mm a",
"@timeOfDayFormat": {
"description": "The ICU 'Short Time' pattern, such as 'HH:mm', 'h:mm a', 'H:mm'. See: http://demo.icu-project.org/icu-bin/locexp?d_=en&_=en_US"
"description": "The ICU 'Short Time' pattern, such as 'HH:mm', 'h:mm a', 'H:mm'. See: http://demo.icu-project.org/icu-bin/locexp?d_=en&_=en_US",
"x-flutter-type": "icuShortTimePattern"
},
"openAppDrawerTooltip": "Open navigation menu",
......
......@@ -10,19 +10,20 @@ import 'package:flutter_test/flutter_test.dart';
void main() {
group(GlobalMaterialLocalizations, () {
test('uses exact locale when exists', () {
final GlobalMaterialLocalizations localizations = new GlobalMaterialLocalizations(const Locale('pt', 'PT'));
test('uses exact locale when exists', () async {
final GlobalMaterialLocalizations localizations = await GlobalMaterialLocalizations.delegate.load(const Locale('pt', 'PT'));
expect(localizations.formatDecimal(10000), '10\u00A0000');
});
test('falls back to language code when exact locale is missing', () {
final GlobalMaterialLocalizations localizations = new GlobalMaterialLocalizations(const Locale('pt', 'XX'));
test('falls back to language code when exact locale is missing', () async {
final GlobalMaterialLocalizations localizations = await GlobalMaterialLocalizations.delegate.load(const Locale('pt', 'XX'));
expect(localizations.formatDecimal(10000), '10.000');
});
test('falls back to default format when neither language code nor exact locale are available', () {
final GlobalMaterialLocalizations localizations = new GlobalMaterialLocalizations(const Locale('xx', 'XX'));
expect(localizations.formatDecimal(10000), '10,000');
test('fails when neither language code nor exact locale are available', () async {
await expectLater(() async {
await GlobalMaterialLocalizations.delegate.load(const Locale('xx', 'XX'));
}, throwsAssertionError);
});
group('formatHour', () {
......@@ -65,8 +66,8 @@ void main() {
});
group('formatMinute', () {
test('formats English', () {
final GlobalMaterialLocalizations localizations = new GlobalMaterialLocalizations(const Locale('en', 'US'));
test('formats English', () async {
final GlobalMaterialLocalizations localizations = await GlobalMaterialLocalizations.delegate.load(const Locale('en', 'US'));
expect(localizations.formatMinute(const TimeOfDay(hour: 1, minute: 32)), '32');
});
});
......
......@@ -6,9 +6,21 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
class FooMaterialLocalizations extends GlobalMaterialLocalizations {
FooMaterialLocalizations(Locale locale, this.backButtonTooltip) : super(locale);
import 'package:intl/intl.dart' as intl;
class FooMaterialLocalizations extends MaterialLocalizationEn {
FooMaterialLocalizations(
Locale localeName,
this.backButtonTooltip,
) : super(
localeName: localeName.toString(),
fullYearFormat: new intl.DateFormat.y(),
mediumDateFormat: new intl.DateFormat('E, MMM\u00a0d'),
longDateFormat: new intl.DateFormat.yMMMMEEEEd(),
yearMonthFormat: new intl.DateFormat.yMMMM(),
decimalFormat: new intl.NumberFormat.decimalPattern(),
twoDigitZeroPaddedFormat: new intl.NumberFormat('00'),
);
@override
final String backButtonTooltip;
......@@ -44,7 +56,7 @@ Widget buildFrame({
LocaleResolutionCallback localeResolutionCallback,
Iterable<Locale> supportedLocales = const <Locale>[
Locale('en', 'US'),
Locale('es', 'es'),
Locale('es', 'ES'),
],
}) {
return new MaterialApp(
......@@ -81,12 +93,12 @@ void main() {
expect(tester.widget<Text>(find.byKey(textKey)).data, 'Back');
// Unrecognized locale falls back to 'en'
await tester.binding.setLocale('foo', 'bar');
await tester.binding.setLocale('foo', 'BAR');
await tester.pump();
expect(tester.widget<Text>(find.byKey(textKey)).data, 'Back');
// Spanish Bolivia locale, falls back to just 'es'
await tester.binding.setLocale('es', 'bo');
await tester.binding.setLocale('es', 'BO');
await tester.pump();
expect(tester.widget<Text>(find.byKey(textKey)).data, 'Atrás');
});
......@@ -169,7 +181,6 @@ void main() {
Locale('de', ''),
],
buildContent: (BuildContext context) {
// Should always be 'foo', no matter what the locale is
return new Text(
MaterialLocalizations.of(context).backButtonTooltip,
key: textKey,
......
......@@ -7,63 +7,13 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// Watch out: this list must be kept in sync with the comment at the top of
// GlobalMaterialLocalizations.
final List<String> languages = <String>[
'ar', // Arabic
'bg', // Bulgarian
'bs', // Bosnian
'ca', // Catalan
'cs', // Czech
'da', // Danish
'de', // German
'el', // Greek
'en', // English
'es', // Spanish
'et', // Estonian
'fa', // Farsi (Persian)
'fi', // Finnish
'fil', // Fillipino
'fr', // French
'gsw', // Swiss German
'he', // Hebrew
'hi', // Hindi
'hr', // Croatian
'hu', // Hungarian
'id', // Indonesian
'it', // Italian
'ja', // Japanese
'ko', // Korean
'lv', // Latvian
'lt', // Lithuanian
'ms', // Malay
'nl', // Dutch
'nb', // Norwegian
'pl', // Polish
'ps', // Pashto
'pt', // Portugese
'ro', // Romanian
'ru', // Russian
'sr', // Serbian
'sk', // Slovak
'sl', // Slovenian
'sv', // Swedish
'th', // Thai
'tl', // Tagalog
'tr', // Turkish
'uk', // Ukranian
'ur', // Urdu
'vi', // Vietnamese
'zh', // Chinese (simplified)
];
for (String language in languages) {
for (String language in kSupportedLanguages) {
testWidgets('translations exist for $language', (WidgetTester tester) async {
final Locale locale = new Locale(language, '');
expect(GlobalMaterialLocalizations.delegate.isSupported(locale), isTrue);
final MaterialLocalizations localizations = new GlobalMaterialLocalizations(locale);
final MaterialLocalizations localizations = await GlobalMaterialLocalizations.delegate.load(locale);
expect(localizations.openAppDrawerTooltip, isNotNull);
expect(localizations.backButtonTooltip, isNotNull);
......@@ -119,22 +69,43 @@ void main() {
}
testWidgets('spot check selectedRowCount translations', (WidgetTester tester) async {
MaterialLocalizations localizations = new GlobalMaterialLocalizations(const Locale('en', ''));
MaterialLocalizations localizations = await GlobalMaterialLocalizations.delegate.load(const Locale('en', ''));
expect(localizations.selectedRowCountTitle(0), 'No items selected');
expect(localizations.selectedRowCountTitle(1), '1 item selected');
expect(localizations.selectedRowCountTitle(2), '2 items selected');
expect(localizations.selectedRowCountTitle(3), '3 items selected');
expect(localizations.selectedRowCountTitle(5), '5 items selected');
expect(localizations.selectedRowCountTitle(10), '10 items selected');
expect(localizations.selectedRowCountTitle(15), '15 items selected');
expect(localizations.selectedRowCountTitle(29), '29 items selected');
expect(localizations.selectedRowCountTitle(10000), '10,000 items selected');
expect(localizations.selectedRowCountTitle(10019), '10,019 items selected');
expect(localizations.selectedRowCountTitle(123456789), '123,456,789 items selected');
localizations = new GlobalMaterialLocalizations(const Locale('es', ''));
localizations = await GlobalMaterialLocalizations.delegate.load(const Locale('es', ''));
expect(localizations.selectedRowCountTitle(0), 'No se han seleccionado elementos');
expect(localizations.selectedRowCountTitle(1), '1 elemento seleccionado');
expect(localizations.selectedRowCountTitle(2), '2 elementos seleccionados');
expect(localizations.selectedRowCountTitle(3), '3 elementos seleccionados');
expect(localizations.selectedRowCountTitle(5), '5 elementos seleccionados');
expect(localizations.selectedRowCountTitle(10), '10 elementos seleccionados');
expect(localizations.selectedRowCountTitle(15), '15 elementos seleccionados');
expect(localizations.selectedRowCountTitle(29), '29 elementos seleccionados');
expect(localizations.selectedRowCountTitle(10000), '10.000 elementos seleccionados');
expect(localizations.selectedRowCountTitle(10019), '10.019 elementos seleccionados');
expect(localizations.selectedRowCountTitle(123456789), '123.456.789 elementos seleccionados');
localizations = new GlobalMaterialLocalizations(const Locale('ro', ''));
localizations = await GlobalMaterialLocalizations.delegate.load(const Locale('ro', ''));
expect(localizations.selectedRowCountTitle(0), 'Nu există elemente selectate');
expect(localizations.selectedRowCountTitle(1), 'Un articol selectat');
expect(localizations.selectedRowCountTitle(2), '2 de articole selectate');
expect(localizations.selectedRowCountTitle(2), '2 articole selectate');
expect(localizations.selectedRowCountTitle(3), '3 articole selectate');
expect(localizations.selectedRowCountTitle(5), '5 articole selectate');
expect(localizations.selectedRowCountTitle(10), '10 articole selectate');
expect(localizations.selectedRowCountTitle(15), '15 articole selectate');
expect(localizations.selectedRowCountTitle(29), '29 de articole selectate');
expect(localizations.selectedRowCountTitle(10000), '10.000 de articole selectate');
expect(localizations.selectedRowCountTitle(10019), '10.019 articole selectate');
expect(localizations.selectedRowCountTitle(123456789), '123.456.789 de articole selectate');
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment