From 4262c1e9d3db98e185aef084a3eccc3d97e098dd Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Fri, 8 Sep 2017 16:47:40 -0700 Subject: [PATCH] Make an app's supported locales configurable (#11946) * Make an app's supported locales configurable * Added an supportedLocales.isNotEmpty assert * WidgetsApp no longer const because supportedLocales.isNotEmpty * updated per review feedback * tweaked dartdoc to restart the build * updated per review feedback * Updated per review feedback --- examples/stocks/lib/main.dart | 4 + packages/flutter/lib/src/material/app.dart | 63 +++++++++++++ packages/flutter/lib/src/widgets/app.dart | 94 ++++++++++++++++++- .../test/material/localizations_test.dart | 13 ++- .../test/widgets/localizations_test.dart | 87 +++++++++++++++-- 5 files changed, 243 insertions(+), 18 deletions(-) diff --git a/examples/stocks/lib/main.dart b/examples/stocks/lib/main.dart index 5d8359bf533..c7c825d16c6 100644 --- a/examples/stocks/lib/main.dart +++ b/examples/stocks/lib/main.dart @@ -121,6 +121,10 @@ class StocksAppState extends State { localizationsDelegates: <_StocksLocalizationsDelegate>[ new _StocksLocalizationsDelegate(), ], + supportedLocales: const [ + const Locale('en', 'US'), + const Locale('es', 'ES'), + ], debugShowMaterialGrid: _configuration.debugShowGrid, showPerformanceOverlay: _configuration.showPerformanceOverlay, showSemanticsDebugger: _configuration.showSemanticsDebugger, diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 4d063603ddd..795a8d5cb84 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -94,6 +94,8 @@ class MaterialApp extends StatefulWidget { this.onUnknownRoute, this.locale, this.localizationsDelegates, + this.localeResolutionCallback, + this.supportedLocales: const [const Locale('en', 'US')], this.navigatorObservers: const [], this.debugShowMaterialGrid: false, this.showPerformanceOverlay: false, @@ -233,6 +235,65 @@ class MaterialApp extends StatefulWidget { /// for this application's [Localizations] widget. final Iterable> localizationsDelegates; + /// This callback is responsible for choosing the app's locale + /// when the app is started, and when the user changes the + /// device's locale. + /// + /// The returned value becomes the locale of this app's [Localizations] + /// widget. The callback's `locale` parameter is the device's locale when + /// the app started, or the device locale the user selected after the app was + /// started. The callback's `supportedLocales` parameter is just the value + /// [supportedLocales]. + /// + /// An app could use this callback to substitute locales based on the app's + /// intended audience. If the device's OS provides a prioritized + /// list of locales, this callback could be used to defer to it. + /// + /// If the callback is null then the resolved locale is: + /// - The callback's `locale` parameter if it's equal to a supported locale. + /// - The first supported locale with the same [Locale.languageCode] as the + /// callback's `locale` parameter. + /// - The first supported locale. + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleResolutionCallback localeResolutionCallback; + + /// The list of locales that this app has been localized for. + /// + /// By default only the American English locale is supported. Apps should + /// configure this list to match the locales they support. + /// + /// This list must not null. It's default value is just + /// `[const Locale('en', 'US')]`. It is simply passed along to the + /// [WidgetsApp] built by this widget. + /// + /// The order of the list matters. By default, if the device's locale doesn't + /// exactly match a locale in [supportedLocales] then the first locale in + /// [supportedLocales] with a matching [Locale.languageCode] is used. If that + /// fails then the first locale in [supportedLocales] is used. The default + /// locale resolution algorithm can be overridden with [localeResolutionCallback]. + /// + /// The material widgets include translations for locales with the following + /// language codes: + /// ``` + /// ar - Arabic + /// de - German + /// en - English + /// es - Spanish + /// fa - Farsi (Persian) + /// fr - French + /// he - Hebrew + /// it - Italian + /// ja - Japanese + /// ps - Pashto + /// pt - Portugese + /// ru - Russian + /// sd - Sindhi + /// ur - Urdu + /// zh - Chinese (simplified) + /// ``` + final Iterable supportedLocales; + /// Turns on a performance overlay. /// /// See also: @@ -399,6 +460,8 @@ class _MaterialAppState extends State { onUnknownRoute: _onUnknownRoute, locale: widget.locale, localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + supportedLocales: widget.supportedLocales, showPerformanceOverlay: widget.showPerformanceOverlay, checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 1c926b2c1d2..d528674d470 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -23,6 +23,17 @@ import 'widget_inspector.dart'; export 'dart:ui' show Locale; +/// The signature of [WidgetsApp.localeResolutionCallback]. +/// +/// A `LocaleResolutionCallback` is responsible for computing the locale of the app's +/// [Localizations] object when the app starts and when user changes the default +/// locale for the device. +/// +/// The `locale` is the device's locale when the app started, or the device +/// locale the user selected after the app was started. The `supportedLocales` +/// parameter is just the value of [WidgetApp.supportedLocales]. +typedef Locale LocaleResolutionCallback(Locale locale, Iterable supportedLocales); + // Delegate that fetches the default (English) strings. class _WidgetsLocalizationsDelegate extends LocalizationsDelegate { const _WidgetsLocalizationsDelegate(); @@ -52,7 +63,10 @@ class WidgetsApp extends StatefulWidget { /// /// The boolean arguments, [color], [navigatorObservers], and /// [onGenerateRoute] must not be null. - const WidgetsApp({ + /// + /// The `supportedLocales` argument must be a list of one or more elements. + /// By default supportedLocales is `[const Locale('en', 'US')]`. + WidgetsApp({ // can't be const because the asserts use methods on Iterable :-( Key key, @required this.onGenerateRoute, this.onUnknownRoute, @@ -63,6 +77,8 @@ class WidgetsApp extends StatefulWidget { this.initialRoute, this.locale, this.localizationsDelegates, + this.localeResolutionCallback, + this.supportedLocales: const [const Locale('en', 'US')], this.showPerformanceOverlay: false, this.checkerboardRasterCacheImages: false, this.checkerboardOffscreenLayers: false, @@ -73,6 +89,7 @@ class WidgetsApp extends StatefulWidget { }) : assert(onGenerateRoute != null), assert(color != null), assert(navigatorObservers != null), + assert(supportedLocales != null && supportedLocales.isNotEmpty), assert(showPerformanceOverlay != null), assert(checkerboardRasterCacheImages != null), assert(checkerboardOffscreenLayers != null), @@ -149,6 +166,55 @@ class WidgetsApp extends StatefulWidget { /// for this application's [Localizations] widget. final Iterable> localizationsDelegates; + /// This callback is responsible for choosing the app's locale + /// when the app is started, and when the user changes the + /// device's locale. + /// + /// The returned value becomes the locale of this app's [Localizations] + /// widget. The callback's `locale` parameter is the device's locale when + /// the app started, or the device locale the user selected after the app was + /// started. The callback's `supportedLocales` parameter is just the value + /// [supportedLocales]. + /// + /// If the callback is null or if it returns null then the resolved locale is: + /// + /// - The callback's `locale` parameter if it's equal to a supported locale. + /// - The first supported locale with the same [Locale.langaugeCode] as the + /// callback's `locale` parameter. + /// - The first locale in [supportedLocales]. + /// + /// See also: + /// + /// * [MaterialApp.localeResolutionCallback], which sets the callback of the + /// [WidgetsApp] it creates. + final LocaleResolutionCallback localeResolutionCallback; + + /// The list of locales that this app has been localized for. + /// + /// By default only the American English locale is supported. Apps should + /// configure this list to match the locales they support. + /// + /// This list must not null. Its default value is just + /// `[const Locale('en', 'US')]`. + /// + /// The order of the list matters. By default, if the device's locale doesn't + /// exactly match a locale in [supportedLocales] then the first locale in + /// [supportedLocales] with a matching [Locale.languageCode] is used. If that + /// fails then the first locale in [supportedLocales] is used. The default + /// locale resolution algorithm can be overridden with [localeResolutionCallback]. + /// + /// See also: + /// + /// * [MaterialApp.supportedLocales], which sets the `supportedLocales` + /// of the [WidgetsApp] it creates. + /// + /// * [localeResolutionCallback], an app callback that resolves the app's locale + /// when the device's locale changes. + /// + /// * [localizationDelegates], which collectively define all of the localized + /// resources used by this app. + final Iterable supportedLocales; + /// Turns on a performance overlay. /// https://flutter.io/debugging/#performanceoverlay final bool showPerformanceOverlay; @@ -231,11 +297,28 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv GlobalObjectKey _navigator; Locale _locale; + Locale _resolveLocale(Locale newLocale, Iterable supportedLocales) { + if (widget.localeResolutionCallback != null) { + final Locale locale = widget.localeResolutionCallback(newLocale, widget.supportedLocales); + if (locale != null) + return locale; + } + + Locale matchesLanguageCode; + for (Locale locale in supportedLocales) { + if (locale == newLocale) + return newLocale; + if (locale.languageCode == newLocale.languageCode) + matchesLanguageCode ??= locale; + } + return matchesLanguageCode ?? supportedLocales.first; + } + @override void initState() { super.initState(); _navigator = new GlobalObjectKey(this); - _locale = ui.window.locale; + _locale = _resolveLocale(ui.window.locale, widget.supportedLocales); WidgetsBinding.instance.addObserver(this); } @@ -273,9 +356,12 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv @override void didChangeLocale(Locale locale) { - if (locale != _locale) { + if (locale == _locale) + return; + final Locale newLocale = _resolveLocale(locale, widget.supportedLocales); + if (newLocale != _locale) { setState(() { - _locale = locale; + _locale = newLocale; }); } } diff --git a/packages/flutter/test/material/localizations_test.dart b/packages/flutter/test/material/localizations_test.dart index 358e752337e..a5067f622a9 100644 --- a/packages/flutter/test/material/localizations_test.dart +++ b/packages/flutter/test/material/localizations_test.dart @@ -12,6 +12,10 @@ Widget buildFrame({ return new MaterialApp( color: const Color(0xFFFFFFFF), locale: locale, + supportedLocales: const [ + const Locale('en', 'US'), + const Locale('es', 'es'), + ], onGenerateRoute: (RouteSettings settings) { return new MaterialPageRoute( builder: (BuildContext context) { @@ -39,15 +43,16 @@ void main() { expect(tester.widget(find.byKey(textKey)).data, 'Back'); + // Unrecognized locale falls back to 'en' + await tester.binding.setLocale('foo', 'bar'); + await tester.pump(); + expect(tester.widget(find.byKey(textKey)).data, 'Back'); + // Spanish Bolivia locale, falls back to just 'es' await tester.binding.setLocale('es', 'bo'); await tester.pump(); expect(tester.widget(find.byKey(textKey)).data, 'Espalda'); - // Unrecognized locale falls back to 'en' - await tester.binding.setLocale('foo', 'bar'); - await tester.pump(); - expect(tester.widget(find.byKey(textKey)).data, 'Back'); }); testWidgets('translations exist for all materia/i18n languages', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/localizations_test.dart b/packages/flutter/test/widgets/localizations_test.dart index 90575e01384..33a18875ef7 100644 --- a/packages/flutter/test/widgets/localizations_test.dart +++ b/packages/flutter/test/widgets/localizations_test.dart @@ -107,11 +107,18 @@ Widget buildFrame({ Locale locale, Iterable> delegates, WidgetBuilder buildContent, + LocaleResolutionCallback localeResolutionCallback, + List supportedLocales: const [ + const Locale('en', 'US'), + const Locale('en', 'GB'), + ], }) { return new WidgetsApp( color: const Color(0xFFFFFFFF), locale: locale, localizationsDelegates: delegates, + localeResolutionCallback: localeResolutionCallback, + supportedLocales: supportedLocales, onGenerateRoute: (RouteSettings settings) { return new PageRouteBuilder( pageBuilder: (BuildContext context, Animation _, Animation __) { @@ -182,7 +189,7 @@ void main() { ); expect(TestLocalizations.of(pageContext), isNotNull); - expect(find.text('_'), findsOneWidget); // default test locale is '_' + expect(find.text('en_US'), findsOneWidget); await tester.binding.setLocale('en', 'GB'); await tester.pump(); @@ -205,25 +212,25 @@ void main() { ) ); await tester.pump(const Duration(milliseconds: 50)); // TestLocalizations.loadAsync() takes 100ms - expect(find.text('_'), findsNothing); // TestLocalizations hasn't been loaded yet + expect(find.text('en_US'), findsNothing); // TestLocalizations hasn't been loaded yet await tester.pump(const Duration(milliseconds: 50)); // TestLocalizations.loadAsync() completes await tester.pumpAndSettle(); - expect(find.text('_'), findsOneWidget); // default test locale is '_' - - await tester.binding.setLocale('en', 'US'); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); - expect(find.text('en_US'), findsOneWidget); + expect(find.text('en_US'), findsOneWidget); // default test locale is US english await tester.binding.setLocale('en', 'GB'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(find.text('en_GB'), findsOneWidget); + + await tester.binding.setLocale('en', 'US'); await tester.pump(const Duration(milliseconds: 50)); // TestLocalizations.loadAsync() hasn't completed yet so the old text // localization is still displayed - expect(find.text('en_US'), findsOneWidget); + expect(find.text('en_GB'), findsOneWidget); await tester.pump(const Duration(milliseconds: 50)); // finish the async load await tester.pumpAndSettle(); - expect(find.text('en_GB'), findsOneWidget); + expect(find.text('en_US'), findsOneWidget); }); testWidgets('Localizations with multiple sync delegates', (WidgetTester tester) async { @@ -422,6 +429,10 @@ void main() { await tester.pumpWidget( buildFrame( + supportedLocales: const [ + const Locale('en', 'GB'), + const Locale('ar', 'EG'), + ], buildContent: (BuildContext context) { pageContext = context; return const Text('Hello World'); @@ -437,6 +448,62 @@ void main() { await tester.pump(); expect(Directionality.of(pageContext), TextDirection.rtl); }); + + testWidgets('localeResolutionCallback override', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + localeResolutionCallback: (Locale newLocale, Iterable supportedLocales) { + return const Locale('foo', 'BAR'); + }, + buildContent: (BuildContext context) { + return new Text(Localizations.localeOf(context).toString()); + } + ) + ); + + await tester.pumpAndSettle(); + expect(find.text('foo_BAR'), findsOneWidget); + + await tester.binding.setLocale('en', 'GB'); + await tester.pumpAndSettle(); + expect(find.text('foo_BAR'), findsOneWidget); + }); + + + testWidgets('supportedLocales and defaultLocaleChangeHandler', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + const Locale('zh', 'CN'), + const Locale('en', 'GB'), + const Locale('en', 'CA'), + ], + buildContent: (BuildContext context) { + return new Text(Localizations.localeOf(context).toString()); + } + ) + ); + + // Startup time. Default test locale is const Locale('', ''), so + // no supported matches. Use the first locale. + await tester.pumpAndSettle(); + expect(find.text('zh_CN'), findsOneWidget); + + // defaultLocaleChangedHandler prefers exact supported locale match + await tester.binding.setLocale('en', 'CA'); + await tester.pumpAndSettle(); + expect(find.text('en_CA'), findsOneWidget); + + // defaultLocaleChangedHandler chooses 1st matching supported locale.languageCode + await tester.binding.setLocale('en', 'US'); + await tester.pumpAndSettle(); + expect(find.text('en_GB'), findsOneWidget); + + // defaultLocaleChangedHandler: no matching supported locale, so use the 1st one + await tester.binding.setLocale('da', 'DA'); + await tester.pumpAndSettle(); + expect(find.text('zh_CN'), findsOneWidget); + }); } // Same as _WidgetsLocalizationsDelegate in widgets/app.dart