diff --git a/packages/flutter/lib/src/cupertino/app.dart b/packages/flutter/lib/src/cupertino/app.dart index 833b39cb743..cde6c4c3a69 100644 --- a/packages/flutter/lib/src/cupertino/app.dart +++ b/packages/flutter/lib/src/cupertino/app.dart @@ -90,6 +90,7 @@ class CupertinoApp extends StatefulWidget { this.color, this.locale, this.localizationsDelegates, + this.localeListResolutionCallback, this.localeResolutionCallback, this.supportedLocales = const [Locale('en', 'US')], this.showPerformanceOverlay = false, @@ -157,6 +158,11 @@ class CupertinoApp extends StatefulWidget { /// {@macro flutter.widgets.widgetsApp.localizationsDelegates} final Iterable> localizationsDelegates; + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleListResolutionCallback localeListResolutionCallback; + /// {@macro flutter.widgets.widgetsApp.localeResolutionCallback} /// /// This callback is passed along to the [WidgetsApp] built by this widget. @@ -283,6 +289,7 @@ class _CupertinoAppState extends State { locale: widget.locale, localizationsDelegates: _localizationsDelegates, localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, supportedLocales: widget.supportedLocales, showPerformanceOverlay: widget.showPerformanceOverlay, checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 7ca708b901e..e079bd931ef 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -98,6 +98,7 @@ class MaterialApp extends StatefulWidget { this.theme, this.locale, this.localizationsDelegates, + this.localeListResolutionCallback, this.localeResolutionCallback, this.supportedLocales = const [Locale('en', 'US')], this.debugShowMaterialGrid = false, @@ -264,6 +265,11 @@ class MaterialApp extends StatefulWidget { /// . final Iterable> localizationsDelegates; + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleListResolutionCallback localeListResolutionCallback; + /// {@macro flutter.widgets.widgetsApp.localeResolutionCallback} /// /// This callback is passed along to the [WidgetsApp] built by this widget. @@ -423,6 +429,7 @@ class _MaterialAppState extends State { locale: widget.locale, localizationsDelegates: _localizationsDelegates, localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, supportedLocales: widget.supportedLocales, showPerformanceOverlay: widget.showPerformanceOverlay, checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 3d47dfa1438..f2893e83d5a 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:collection' show HashMap; import 'dart:ui' as ui show window; import 'package:flutter/foundation.dart'; @@ -24,19 +25,50 @@ import 'widget_inspector.dart'; export 'dart:ui' show Locale; +/// The signature of [WidgetsApp.localeListResolutionCallback]. +/// +/// A [LocaleListResolutionCallback] is responsible for computing the locale of the app's +/// [Localizations] object when the app starts and when user changes the list of +/// locales for the device. +/// +/// The [locales] list is the device's preferred locales when the app started, or the +/// device's preferred locales the user selected after the app was started. This list +/// is in order of preference. If this list is null or empty, then Flutter has not yet +/// recieved the locale information from the platform. The [supportedLocales] parameter +/// is just the value of [WidgetsApp.supportedLocales]. +/// +/// See also: +/// +/// * [LocaleResolutionCallback], which takes only one default locale (instead of a list) +/// and is attempted only after this callback fails or is null. [LocaleListResolutionCallback] +/// is recommended over [LocaleResolutionCallback]. +typedef LocaleListResolutionCallback = Locale Function(List locales, Iterable supportedLocales); + /// The signature of [WidgetsApp.localeResolutionCallback]. /// -/// A `LocaleResolutionCallback` is responsible for computing the locale of the app's +/// It is recommended to provide a [LocaleListResolutionCallback] instead of a +/// [LocaleResolutionCallback] when possible, as [LocaleListResolutionCallback] as +/// this callback only recieves a subset of the information provided +/// in [LocaleListResolutionCallback]. +/// +/// 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. +/// locale for the device after [LocaleListResolutionCallback] fails or is not provided. /// /// This callback is also used if the app is created with a specific locale using /// the [new WidgetsApp] `locale` parameter. /// -/// The `locale` is either the value of [WidgetsApp.locale], or the device's -/// locale when the app started, or the device locale the user selected after -/// the app was started. The `supportedLocales` parameter is the value of +/// The [locale] is either the value of [WidgetsApp.locale], or the device's default +/// locale when the app started, or the device locale the user selected after the app +/// was started. The default locale is the first locale in the list of preferred +/// locales. If [locale] is null, then Flutter has not yet recieved the locale +/// information from the platform. The [supportedLocales] parameter is just the value of /// [WidgetsApp.supportedLocales]. +/// +/// See also: +/// +/// * [LocaleListResolutionCallback], which takes a list of preferred locales (instead of one locale). +/// Resolutions by [LocaleListResolutionCallback] take precedence over [LocaleResolutionCallback]. typedef LocaleResolutionCallback = Locale Function(Locale locale, Iterable supportedLocales); /// The signature of [WidgetsApp.onGenerateTitle]. @@ -124,6 +156,7 @@ class WidgetsApp extends StatefulWidget { @required this.color, this.locale, this.localizationsDelegates, + this.localeListResolutionCallback, this.localeResolutionCallback, this.supportedLocales = const [Locale('en', 'US')], this.showPerformanceOverlay = false, @@ -468,29 +501,49 @@ class WidgetsApp extends StatefulWidget { /// {@endtemplate} final Iterable> localizationsDelegates; - /// {@template flutter.widgets.widgetsApp.localeResolutionCallback} + /// {@template flutter.widgets.widgetsApp.localeListResolutionCallback} /// 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]. + /// When a [localeListResolutionCallback] is provided, Flutter will first attempt to + /// resolve the locale with the provided [localeListResolutionCallback]. If the + /// callback or result is null, it will fallback to trying the [localeResolutionCallback]. + /// If both [localeResolutionCallback] and [localeListResolutionCallback] are left null + /// or fail to resolve (return null), the [WidgetsApp.basicLocaleListResolution] + /// fallback algorithm will be used. /// - /// If the callback is null or if it returns null then the resolved locale is: + /// The priority of each available fallback 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 locale in [supportedLocales]. + /// 1. [localeListResolutionCallback] is attempted first. + /// 2. [localeResolutionCallback] is attempted second. + /// 3. Flutter's [WidgetsApp.basicLocaleListResolution] algorithm is attempted last. /// {@endtemplate} /// + /// This callback considers the entire list of preferred locales. + /// + /// This algorithm should be able to handle a null or empty list of preferred locales, + /// which indicates Flutter has not yet recieved locale information from the platform. + /// /// See also: /// /// * [MaterialApp.localeResolutionCallback], which sets the callback of the /// [WidgetsApp] it creates. + final LocaleListResolutionCallback localeListResolutionCallback; + + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + /// + /// This callback considers only the default locale, which is the first locale + /// in the preferred locales list. It is preferred to set [localeListResolutionCallback] + /// over [localeResolutionCallback] as it provides the full preferred locales list. + /// + /// This algorithm should be able to handle a null locale, which indicates + /// Flutter has not yet recieved locale information from the platform. + /// + /// See also: + /// + /// * [MaterialApp.localeListResolutionCallback], which sets the callback of the + /// [WidgetsApp] it creates. final LocaleResolutionCallback localeResolutionCallback; /// {@template flutter.widgets.widgetsApp.supportedLocales} @@ -607,7 +660,7 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv void initState() { super.initState(); _updateNavigator(); - _locale = _resolveLocale(ui.window.locale, widget.supportedLocales); + _locale = _resolveLocales(ui.window.locales, widget.supportedLocales); WidgetsBinding.instance.addObserver(this); } @@ -717,35 +770,147 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv // LOCALIZATION + /// This is the resolved locale, and is one of the supportedLocales. Locale _locale; - Locale _resolveLocale(Locale newLocale, Iterable supportedLocales) { - if (widget.localeResolutionCallback != null) { - final Locale locale = widget.localeResolutionCallback(newLocale, widget.supportedLocales); + Locale _resolveLocales(List preferredLocales, Iterable supportedLocales) { + // Attempt to use localeListResolutionCallback. + if (widget.localeListResolutionCallback != null) { + final Locale locale = widget.localeListResolutionCallback(preferredLocales, widget.supportedLocales); if (locale != null) return locale; } - // newLocale can be null when called before the platform has had a chance to - // initialize the locales. We default to the first supported locale. - if (newLocale == null) { + // localeListResolutionCallback failed, falling back to localeResolutionCallback. + if (widget.localeResolutionCallback != null) { + final Locale locale = widget.localeResolutionCallback(preferredLocales.first, widget.supportedLocales); + if (locale != null) + return locale; + } + // Both callbacks failed, falling back to default algorithm. + return basicLocaleListResolution(preferredLocales, supportedLocales); + } + + /// The default locale resolution algorithm. + /// + /// Custom resolution algorithms can be provided through [WidgetsApp.localeListResolutionCallback] + /// or [WidgetsApp.localeResolutionCallback]. + /// + /// When no custom locale resolition algorithms are provided or if both fail to resolve, + /// Flutter will default to calling this algorithm. + /// + /// This algorithm prioritizes speed at the cost of slightly less appropriate + /// resolutions for edge cases. + /// + /// This algorithm will resolve to the earliest locale in [preferredLocales] that + /// matches the most fields, prioritizing in the order of perfect match, + /// languageCode+countryCode, languageCode+scriptCode, languageCode-only. + /// + /// In the case where a locale is matched by languageCode-only and is not the + /// default (first) locale, the next locale in preferredLocales with a + /// perfect match can supercede the languageCode-only match if it exists. + /// + /// When a preferredLocale matches more than one supported locale, it will resolve + /// to the first matching locale listed in the supportedLocales. + /// + /// When all [preferredLocales] have been exhausted without a match, the first countryCode only + /// match will be returned. + /// + /// When no match at all is found, the first (default) locale in [supportedLocales] will be + /// returned. + /// + /// This algorithm does not take language distance (how similar languages are to each other) + /// into account, and will not handle edge cases such as resolving `de` to `fr` rather than `zh` + /// when `de` is not supported and `zh` is listed before `fr` (German is closer to French + /// than Chinese). + static Locale basicLocaleListResolution(List preferredLocales, Iterable supportedLocales) { + // preferredLocales can be null when called before the platform has had a chance to + // initialize the locales. Platforms without locale passing support will provide an empty list. + // We default to the first supported locale in these cases. + if (preferredLocales == null || preferredLocales.isEmpty) { return supportedLocales.first; } - - Locale matchesLanguageCode; + // Hash the supported locales because apps can support many locales and would + // be expensive to search through them many times. + final Map allSupportedLocales = HashMap(); + final Map languageAndCountryLocales = HashMap(); + final Map languageAndScriptLocales = HashMap(); + final Map languageLocales = HashMap(); + final Map countryLocales = HashMap(); for (Locale locale in supportedLocales) { - if (locale == newLocale) - return newLocale; - if (locale.languageCode == newLocale.languageCode) - matchesLanguageCode ??= locale; + allSupportedLocales['${locale.languageCode}_${locale.scriptCode}_${locale.countryCode}'] ??= locale; + languageAndScriptLocales['${locale.languageCode}_${locale.scriptCode}'] ??= locale; + languageAndCountryLocales['${locale.languageCode}_${locale.countryCode}'] ??= locale; + languageLocales[locale.languageCode] ??= locale; + countryLocales[locale.countryCode] ??= locale; } - return matchesLanguageCode ?? supportedLocales.first; + + // Since languageCode-only matches are possibly low quality, we don't return + // it instantly when we find such a match. We check to see if the next + // preferred locale in the list has a high accuracy match, and only return + // the languageCode-only match when a higher accuracy match in the next + // preferred locale cannot be found. + Locale matchesLanguageCode; + Locale matchesCountryCode; + // Loop over user's preferred locales + for (int localeIndex = 0; localeIndex < preferredLocales.length; localeIndex += 1) { + final Locale userLocale = preferredLocales[localeIndex]; + // Look for perfect match. + if (allSupportedLocales.containsKey('${userLocale.languageCode}_${userLocale.scriptCode}_${userLocale.countryCode}')) { + return userLocale; + } + // Look for language+script match. + if (userLocale.scriptCode != null) { + final Locale match = languageAndScriptLocales['${userLocale.languageCode}_${userLocale.scriptCode}']; + if (match != null) { + return match; + } + } + // Look for language+country match. + if (userLocale.countryCode != null) { + final Locale match = languageAndCountryLocales['${userLocale.languageCode}_${userLocale.countryCode}']; + if (match != null) { + return match; + } + } + // If there was a languageCode-only match in the previous iteration's higher + // ranked preferred locale, we return it if the current userLocale does not + // have a better match. + if (matchesLanguageCode != null) { + return matchesLanguageCode; + } + // Look and store language-only match. + Locale match = languageLocales[userLocale.languageCode]; + if (match != null) { + matchesLanguageCode = match; + // Since first (default) locale is usually highly preferred, we will allow + // a languageCode-only match to be instantly matched. If the next preferred + // languageCode is the same, we defer hastily returning until the next iteration + // since at worst it is the same and at best an improved match. + if (localeIndex == 0 && + !(localeIndex + 1 < preferredLocales.length && preferredLocales[localeIndex + 1].languageCode == userLocale.languageCode)) { + return matchesLanguageCode; + } + } + // countryCode-only match. When all else except default supported locale fails, + // attempt to match by country only, as a user is likely to be familar with a + // language from their listed country. + if (matchesCountryCode == null && userLocale.countryCode != null) { + match = countryLocales[userLocale.countryCode]; + if (match != null) { + matchesCountryCode = match; + } + } + } + // When there is no languageCode-only match. Fallback to matching countryCode only. Country + // fallback only applies on iOS. When there is no countryCode-only match, we return first + // suported locale. + final Locale resolvedLocale = matchesLanguageCode ?? matchesCountryCode ?? supportedLocales.first; + return resolvedLocale; } @override - void didChangeLocale(Locale locale) { - if (locale == _locale) - return; - final Locale newLocale = _resolveLocale(locale, widget.supportedLocales); + void didChangeLocales(List locales) { + final Locale newLocale = _resolveLocales(locales, widget.supportedLocales); if (newLocale != _locale) { setState(() { _locale = newLocale; @@ -950,7 +1115,7 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv } final Locale appLocale = widget.locale != null - ? _resolveLocale(widget.locale, widget.supportedLocales) + ? _resolveLocales([widget.locale], widget.supportedLocales) : _locale; assert(_debugCheckLocalizations(appLocale)); diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index b4593bb0eee..cb8bd17f135 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -219,7 +219,7 @@ abstract class WidgetsBindingObserver { /// settings. /// /// This method exposes notifications from [Window.onLocaleChanged]. - void didChangeLocale(Locale locale) { } + void didChangeLocales(List locale) { } /// Called when the system puts the app in the background or returns /// the app to the foreground. @@ -413,20 +413,20 @@ mixin WidgetsBinding on BindingBase, SchedulerBinding, GestureBinding, RendererB @protected @mustCallSuper void handleLocaleChanged() { - dispatchLocaleChanged(ui.window.locale); + dispatchLocalesChanged(ui.window.locales); } /// Notify all the observers that the locale has changed (using - /// [WidgetsBindingObserver.didChangeLocale]), giving them the - /// `locale` argument. + /// [WidgetsBindingObserver.didChangeLocales]), giving them the + /// `locales` argument. /// /// This is called by [handleLocaleChanged] when the [Window.onLocaleChanged] /// notification is received. @protected @mustCallSuper - void dispatchLocaleChanged(Locale locale) { + void dispatchLocalesChanged(List locales) { for (WidgetsBindingObserver observer in _observers) - observer.didChangeLocale(locale); + observer.didChangeLocales(locales); } /// Notify all the observers that the active set of [AccessibilityFeatures] diff --git a/packages/flutter_localizations/test/widgets_test.dart b/packages/flutter_localizations/test/widgets_test.dart index 884ea3a3699..ec459b08bc6 100644 --- a/packages/flutter_localizations/test/widgets_test.dart +++ b/packages/flutter_localizations/test/widgets_test.dart @@ -745,4 +745,673 @@ void main() { await tester.pumpAndSettle(); expect(find.text('zh_CN'), findsOneWidget); }); + + // Example from http://unicode.org/reports/tr35/#LanguageMatching + testWidgets('WidgetsApp Unicode tr35 1', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale('de'), + Locale('fr'), + Locale('ja'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'de', countryCode: 'AT'), + Locale.fromSubtags(languageCode: 'fr'),] + ); + await tester.pumpAndSettle(); + expect(find.text('de'), findsOneWidget); + }); + + // Examples from http://unicode.org/reports/tr35/#LanguageMatching + testWidgets('WidgetsApp Unicode tr35 2', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale('ja', 'JP'), + Locale('de'), + Locale('zh', 'TW'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'de'), + Locale.fromSubtags(languageCode: 'fr'), + Locale.fromSubtags(languageCode: 'de', countryCode: 'SW'), + Locale.fromSubtags(languageCode: 'it'),] + ); + await tester.pumpAndSettle(); + expect(find.text('de'), findsOneWidget); + }); + + testWidgets('WidgetsApp EdgeCase Chinese', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale.fromSubtags(languageCode: 'zh'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'de'), + Locale.fromSubtags(languageCode: 'fr'), + Locale.fromSubtags(languageCode: 'de', countryCode: 'SW'), + Locale.fromSubtags(languageCode: 'zh'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'HK'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'HK'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh'), findsOneWidget); + + // This behavior is up to the implementer to decide if a perfect scriptCode match + // is better than a countryCode match. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'CN'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'), + Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + + // languageCode only match is not enough to prevent resolving a perfect match + // further down the preferredLocales list. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'JP'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + // When no language match, we try for country only, since it is likely users are + // at least familiar with their country's language. This is a possible case only + // on iOS, where countryCode can be selected independently from language and script. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', scriptCode: 'Hans', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'HK'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + }); + + // Same as 'WidgetsApp EdgeCase Chinese' test except the supportedLocales order is + // reversed. + testWidgets('WidgetsApp EdgeCase ReverseChinese', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'), + Locale.fromSubtags(languageCode: 'zh'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'de'), + Locale.fromSubtags(languageCode: 'fr'), + Locale.fromSubtags(languageCode: 'de', countryCode: 'SW'), + Locale.fromSubtags(languageCode: 'zh'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'HK'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'HK'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + + // This behavior is up to the implementer to decide if a perfect scriptCode match + // is better than a countryCode match. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'CN'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'), + Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + + // languageCode only match is not enough to prevent resolving a perfect match + // further down the preferredLocales list. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'JP'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'zh', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hans_CN'), findsOneWidget); + + // When no language match, we try for country only, since it is likely users are + // at least familiar with their country's language. This is a possible case only + // on iOS, where countryCode can be selected independently from language and script. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', scriptCode: 'Hans', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'TW'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_TW'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'HK'),] + ); + await tester.pumpAndSettle(); + expect(find.text('zh_Hant_HK'), findsOneWidget); + }); + + // Examples from https://developer.android.com/guide/topics/resources/multilingual-support + testWidgets('WidgetsApp Android', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale('en'), + Locale('de', 'DE'), + Locale('es', 'ES'), + Locale('fr', 'FR'), + Locale('it', 'IT'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'fr', countryCode: 'CH'),] + ); + await tester.pumpAndSettle(); + expect(find.text('fr_FR'), findsOneWidget); + }); + + // Examples from https://developer.android.com/guide/topics/resources/multilingual-support + testWidgets('WidgetsApp Android', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale('en'), + Locale('de', 'DE'), + Locale('es', 'ES'), + Locale('it', 'IT'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'fr', countryCode: 'CH'), + Locale.fromSubtags(languageCode: 'it', countryCode: 'CH'),] + ); + await tester.pumpAndSettle(); + expect(find.text('it_IT'), findsOneWidget); + }); + + testWidgets('WidgetsApp Country-only fallback', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale('en', 'US'), + Locale('de', 'DE'), + Locale('de', 'AU'), + Locale('de', 'LU'), + Locale('de', 'CH'), + Locale('es', 'ES'), + Locale('es', 'US'), + Locale('it', 'IT'), + Locale('zh', 'CN'), + Locale('zh', 'TW'), + Locale('fr', 'FR'), + Locale('br', 'FR'), + Locale('pt', 'BR'), + Locale('pt', 'PT'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'CH'),] + ); + await tester.pumpAndSettle(); + expect(find.text('de_CH'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'FR'),] + ); + await tester.pumpAndSettle(); + expect(find.text('fr_FR'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_US'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'es', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('es_US'), findsOneWidget); + + // Strongly prefer matching first locale even if next one is perfect. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'pt'), + Locale.fromSubtags(languageCode: 'pt', countryCode: 'PT'),] + ); + await tester.pumpAndSettle(); + expect(find.text('pt_PT'), findsOneWidget); + + // Don't country match with any other available match. This behavior is + // up for reconsideration. + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'BR'), + Locale.fromSubtags(languageCode: 'pt'),] + ); + await tester.pumpAndSettle(); + expect(find.text('pt_BR'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'BR'), + Locale.fromSubtags(languageCode: 'pt', countryCode: 'PT'),] + ); + await tester.pumpAndSettle(); + expect(find.text('pt_PT'), findsOneWidget); + }); + + // Simulates a Chinese-default app that supports english in Canada but not + // French. French-Canadian users should get 'en_CA' instead of Chinese. + testWidgets('WidgetsApp Multilingual country', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + supportedLocales: const [ + Locale('zh', 'CN'), + Locale('en', 'CA'), + Locale('en', 'US'), + Locale('en', 'AU'), + Locale('de', 'DE'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'fr', countryCode: 'CA'), + Locale.fromSubtags(languageCode: 'fr'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_CA'), findsOneWidget); + }); + + + testWidgets('WidgetsApp Common cases', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + // Decently well localized app. + supportedLocales: const [ + Locale('en', 'US'), + Locale('en', 'GB'), + Locale('en', 'AU'), + Locale('en', 'CA'), + Locale('zh', 'CN'), + Locale('zh', 'TW'), + Locale('de', 'DE'), + Locale('de', 'CH'), + Locale('es', 'MX'), + Locale('es', 'ES'), + Locale('es', 'AR'), + Locale('es', 'CO'), + Locale('ru', 'RU'), + Locale('fr', 'FR'), + Locale('fr', 'CA'), + Locale('ar', 'SA'), + Locale('ar', 'EG'), + Locale('ar', 'IQ'), + Locale('ar', 'MA'), + Locale('af'), + Locale('bg'), + Locale('nl', 'NL'), + Locale('pl'), + Locale('cs'), + Locale('fa'), + Locale('el'), + Locale('he'), + Locale('hi'), + Locale('pa'), + Locale('ta'), + Locale('id'), + Locale('it', 'IT'), + Locale('ja'), + Locale('ko'), + Locale('ms'), + Locale('mn'), + Locale('pt', 'BR'), + Locale('pt', 'PT'), + Locale('sv', 'SE'), + Locale('th'), + Locale('tr'), + Locale('vi'), + ], + buildContent: (BuildContext context) { + final Locale locale = Localizations.localeOf(context); + return Text('$locale'); + } + ) + ); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_US'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_US'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'CA'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_CA'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'AU'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_AU'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'CH'),] + ); + await tester.pumpAndSettle(); + expect(find.text('ar_SA'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar'),] + ); + await tester.pumpAndSettle(); + expect(find.text('ar_SA'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ar', countryCode: 'IQ'),] + ); + await tester.pumpAndSettle(); + expect(find.text('ar_IQ'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'es', countryCode: 'ES'),] + ); + await tester.pumpAndSettle(); + expect(find.text('es_ES'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'es'),] + ); + await tester.pumpAndSettle(); + expect(find.text('es_MX'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'pa', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('pa'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'hi', countryCode: 'IN'),] + ); + await tester.pumpAndSettle(); + expect(find.text('hi'), findsOneWidget); + + // Multiple preferred locales: + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'NZ'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'AU'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'GB'), + Locale.fromSubtags(languageCode: 'en'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_AU'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'ab'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'NZ'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'AU'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'GB'), + Locale.fromSubtags(languageCode: 'en'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_AU'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'NZ'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'PH'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'ZA'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'CB'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_US'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'en', countryCode: 'CA'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'AU'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'GB'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_CA'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'da'), + Locale.fromSubtags(languageCode: 'en'), + Locale.fromSubtags(languageCode: 'en', countryCode: 'CA'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_CA'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'da'), + Locale.fromSubtags(languageCode: 'fo'), + Locale.fromSubtags(languageCode: 'hr'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_US'), findsOneWidget); + + await tester.binding.setLocales(const [ + Locale.fromSubtags(languageCode: 'da'), + Locale.fromSubtags(languageCode: 'fo'), + Locale.fromSubtags(languageCode: 'hr', countryCode: 'CA'),] + ); + await tester.pumpAndSettle(); + expect(find.text('en_CA'), findsOneWidget); + }); } diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 2b759687d90..6c1ab9b77cd 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -225,13 +225,25 @@ abstract class TestWidgetsFlutterBinding extends BindingBase Duration additionalTime = const Duration(milliseconds: 250), }); - /// Artificially calls dispatchLocaleChanged on the Widget binding, + /// Artificially calls dispatchLocalesChanged on the Widget binding, /// then flushes microtasks. + /// + /// Passes only one single Locale. Use [setLocales] to pass a full preferred + /// locales list. Future setLocale(String languageCode, String countryCode) { return TestAsyncUtils.guard(() async { assert(inTest); - final Locale locale = Locale(languageCode, countryCode); - dispatchLocaleChanged(locale); + final Locale locale = Locale(languageCode, countryCode == '' ? null : countryCode); + dispatchLocalesChanged([locale]); + }); + } + + /// Artificially calls dispatchLocalesChanged on the Widget binding, + /// then flushes microtasks. + Future setLocales(List locales) { + return TestAsyncUtils.guard(() async { + assert(inTest); + dispatchLocalesChanged(locales); }); }