diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart index 9d4101ee87a..a2d03c018b1 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -27,9 +27,16 @@ class DomRenderer { static const int vibrateHeavyImpact = 30; static const int vibrateSelectionClick = 10; + /// Fires when browser language preferences change. + static const html.EventStreamProvider languageChangeEvent = + const html.EventStreamProvider('languagechange'); + /// Listens to window resize events. StreamSubscription _resizeSubscription; + /// Listens to window locale events. + StreamSubscription _localeSubscription; + /// Contains Flutter-specific CSS rules, such as default margins and /// paddings. html.StyleElement _styleElement; @@ -85,6 +92,7 @@ class DomRenderer { registerHotRestartListener(() { _resizeSubscription?.cancel(); + _localeSubscription?.cancel(); _staleHotRestartState.addAll([ _glassPaneElement, _styleElement, @@ -462,6 +470,9 @@ flt-glass-pane * { } else { _resizeSubscription = html.window.onResize.listen(_metricsDidChange); } + _localeSubscription = languageChangeEvent.forTarget(html.window) + .listen(_languageDidChange); + window._updateLocales(); } /// Called immediately after browser window metrics change. @@ -485,6 +496,14 @@ flt-glass-pane * { } } + /// Called immediately after browser window language change. + void _languageDidChange(html.Event event) { + window._updateLocales(); + if (ui.window.onLocaleChanged != null) { + ui.window.onLocaleChanged(); + } + } + void focus(html.Element element) { element.focus(); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart index 819ecd695e4..9d8afd9b4eb 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart @@ -181,7 +181,8 @@ class EngineWindow extends ui.Window { } @override - ui.VoidCallback get onPlatformBrightnessChanged => _onPlatformBrightnessChanged; + ui.VoidCallback get onPlatformBrightnessChanged => + _onPlatformBrightnessChanged; ui.VoidCallback _onPlatformBrightnessChanged; Zone _onPlatformBrightnessChangedZone; @override @@ -224,6 +225,64 @@ class EngineWindow extends ui.Window { _onLocaleChangedZone = Zone.current; } + /// The locale used when we fail to get the list from the browser. + static const _defaultLocale = const ui.Locale('en', 'US'); + + /// We use the first locale in the [locales] list instead of the browser's + /// built-in `navigator.language` because browsers do not agree on the + /// implementation. + /// + /// See also: + /// + /// * https://developer.mozilla.org/en-US/docs/Web/API/NavigatorLanguage/languages, + /// which explains browser quirks in the implementation notes. + @override + ui.Locale get locale => _locales.first; + + @override + List get locales => _locales; + List _locales = parseBrowserLanguages(); + + /// Sets locales to `null`. + /// + /// `null` is not a valid value for locales. This is only used for testing + /// locale update logic. + void debugResetLocales() { + _locales = null; + } + + // Called by DomRenderer when browser languages change. + void _updateLocales() { + _locales = parseBrowserLanguages(); + } + + static List parseBrowserLanguages() { + // TODO(yjbanov): find a solution for IE + final bool languagesFeatureMissing = !js_util.hasProperty(html.window.navigator, 'languages'); + if (languagesFeatureMissing || html.window.navigator.languages.isEmpty) { + // To make it easier for the app code, let's not leave the locales list + // empty. This way there's fewer corner cases for apps to handle. + return const [_defaultLocale]; + } + + final List locales = []; + for (final String language in html.window.navigator.languages) { + final List parts = language.split('-'); + if (parts.length > 1) { + locales.add(ui.Locale(parts.first, parts.last)); + } else { + locales.add(ui.Locale(language)); + } + } + + assert(locales.isNotEmpty); + return locales; + } + + /// On the web "platform" is the browser, so it's the same as [locale]. + @override + ui.Locale get platformResolvedLocale => locale; + /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. void invokeOnLocaleChanged() { @@ -259,7 +318,8 @@ class EngineWindow extends ui.Window { /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. void invokeOnReportTimings(List timings) { - _invoke1>(_onReportTimings, _onReportTimingsZone, timings); + _invoke1>( + _onReportTimings, _onReportTimingsZone, timings); } @override @@ -291,7 +351,8 @@ class EngineWindow extends ui.Window { /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. void invokeOnPointerDataPacket(ui.PointerDataPacket packet) { - _invoke1(_onPointerDataPacket, _onPointerDataPacketZone, packet); + _invoke1( + _onPointerDataPacket, _onPointerDataPacketZone, packet); } @override @@ -322,13 +383,15 @@ class EngineWindow extends ui.Window { /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. - void invokeOnSemanticsAction(int id, ui.SemanticsAction action, ByteData args) { - _invoke3(_onSemanticsAction, - _onSemanticsActionZone, id, action, args); + void invokeOnSemanticsAction( + int id, ui.SemanticsAction action, ByteData args) { + _invoke3( + _onSemanticsAction, _onSemanticsActionZone, id, action, args); } @override - ui.VoidCallback get onAccessibilityFeaturesChanged => _onAccessibilityFeaturesChanged; + ui.VoidCallback get onAccessibilityFeaturesChanged => + _onAccessibilityFeaturesChanged; ui.VoidCallback _onAccessibilityFeaturesChanged; Zone _onAccessibilityFeaturesChangedZone; @override @@ -340,7 +403,8 @@ class EngineWindow extends ui.Window { /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. void invokeOnAccessibilityFeaturesChanged() { - _invoke(_onAccessibilityFeaturesChanged, _onAccessibilityFeaturesChangedZone); + _invoke( + _onAccessibilityFeaturesChanged, _onAccessibilityFeaturesChangedZone); } @override @@ -355,7 +419,8 @@ class EngineWindow extends ui.Window { /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. - void invokeOnPlatformMessage(String name, ByteData data, ui.PlatformMessageResponseCallback callback) { + void invokeOnPlatformMessage( + String name, ByteData data, ui.PlatformMessageResponseCallback callback) { _invoke3( _onPlatformMessage, _onPlatformMessageZone, @@ -371,14 +436,16 @@ class EngineWindow extends ui.Window { ByteData/*?*/ data, ui.PlatformMessageResponseCallback/*?*/ callback, ) { - _sendPlatformMessage(name, data, _zonedPlatformMessageResponseCallback(callback)); + _sendPlatformMessage( + name, data, _zonedPlatformMessageResponseCallback(callback)); } /// Wraps the given [callback] in another callback that ensures that the /// original callback is called in the zone it was registered in. static ui.PlatformMessageResponseCallback/*?*/ _zonedPlatformMessageResponseCallback(ui.PlatformMessageResponseCallback/*?*/ callback) { - if (callback == null) + if (callback == null) { return null; + } // Store the zone in which the callback is being registered. final Zone registrationZone = Zone.current; @@ -434,13 +501,15 @@ class EngineWindow extends ui.Window { case 'HapticFeedback.vibrate': final String type = decoded.arguments; domRenderer.vibrate(_getHapticFeedbackDuration(type)); - _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + _replyToPlatformMessage( + callback, codec.encodeSuccessEnvelope(true)); return; case 'SystemChrome.setApplicationSwitcherDescription': final Map arguments = decoded.arguments; domRenderer.setTitle(arguments['label']); domRenderer.setThemeColor(ui.Color(arguments['primaryColor'])); - _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + _replyToPlatformMessage( + callback, codec.encodeSuccessEnvelope(true)); return; case 'SystemChrome.setPreferredOrientations': final List arguments = decoded.arguments; @@ -451,7 +520,8 @@ class EngineWindow extends ui.Window { return; case 'SystemSound.play': // There are no default system sounds on web. - _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + _replyToPlatformMessage( + callback, codec.encodeSuccessEnvelope(true)); return; case 'Clipboard.setData': ClipboardMessageHandler().setDataMethodCall(decoded, callback); @@ -468,9 +538,10 @@ class EngineWindow extends ui.Window { case 'flutter/web_test_e2e': const MethodCodec codec = JSONMethodCodec(); - _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope( - _handleWebTestEnd2EndMessage(codec, data) - )); + _replyToPlatformMessage( + callback, + codec.encodeSuccessEnvelope( + _handleWebTestEnd2EndMessage(codec, data))); return; case 'flutter/platform_views': @@ -497,11 +568,13 @@ class EngineWindow extends ui.Window { case 'routePushed': case 'routeReplaced': _browserHistory.setRouteName(message['routeName']); - _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + _replyToPlatformMessage( + callback, codec.encodeSuccessEnvelope(true)); break; case 'routePopped': _browserHistory.setRouteName(message['previousRouteName']); - _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + _replyToPlatformMessage( + callback, codec.encodeSuccessEnvelope(true)); break; } // As soon as Flutter starts taking control of the app navigation, we @@ -621,7 +694,7 @@ class EngineWindow extends ui.Window { bool _handleWebTestEnd2EndMessage(MethodCodec codec, ByteData data) { final MethodCall decoded = codec.decodeMethodCall(data); double ratio = double.parse(decoded.arguments); - switch(decoded.method) { + switch (decoded.method) { case 'setDevicePixelRatio': window.debugOverrideDevicePixelRatio(ratio); window.onMetricsChanged(); @@ -632,8 +705,9 @@ bool _handleWebTestEnd2EndMessage(MethodCodec codec, ByteData data) { /// Invokes [callback] inside the given [zone]. void _invoke(void callback(), Zone zone) { - if (callback == null) + if (callback == null) { return; + } assert(zone != null); @@ -646,8 +720,9 @@ void _invoke(void callback(), Zone zone) { /// Invokes [callback] inside the given [zone] passing it [arg]. void _invoke1(void callback(A a), Zone zone, A arg) { - if (callback == null) + if (callback == null) { return; + } assert(zone != null); @@ -659,9 +734,11 @@ void _invoke1(void callback(A a), Zone zone, A arg) { } /// Invokes [callback] inside the given [zone] passing it [arg1], [arg2], and [arg3]. -void _invoke3(void callback(A1 a1, A2 a2, A3 a3), Zone zone, A1 arg1, A2 arg2, A3 arg3) { - if (callback == null) +void _invoke3( + void callback(A1 a1, A2 a2, A3 a3), Zone zone, A1 arg1, A2 arg2, A3 arg3) { + if (callback == null) { return; + } assert(zone != null); diff --git a/engine/src/flutter/lib/web_ui/lib/src/ui/window.dart b/engine/src/flutter/lib/web_ui/lib/src/ui/window.dart index 54c2b3306e2..90f6f44f2bf 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/ui/window.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/ui/window.dart @@ -668,8 +668,6 @@ abstract class Window { VoidCallback get onMetricsChanged; set onMetricsChanged(VoidCallback callback); - static const _enUS = const Locale('en', 'US'); - /// The system-reported default locale of the device. /// /// This establishes the language and formatting conventions that application @@ -680,12 +678,7 @@ abstract class Window { /// /// This is equivalent to `locales.first` and will provide an empty non-null locale /// if the [locales] list has not been set or is empty. - Locale get locale { - if (_locales != null && _locales.isNotEmpty) { - return _locales.first; - } - return null; - } + Locale get locale; /// The full system-reported supported locales of the device. /// @@ -701,23 +694,19 @@ abstract class Window { /// /// * [WidgetsBindingObserver], for a mechanism at the widgets layer to /// observe when this value changes. - List get locales => _locales; - // TODO(flutter_web): Get the real locale from the browser. - List _locales = const [_enUS]; + List get locales; /// The locale that the platform's native locale resolution system resolves to. /// /// This value may differ between platforms and is meant to allow flutter locale - /// resoltion algorithms to into resolving consistently with other apps on the + /// resolution algorithms to into resolving consistently with other apps on the /// device. /// /// This value may be used in a custom [localeListResolutionCallback] or used directly /// in order to arrive at the most appropriate locale for the app. /// /// See [locales], which is the list of locales the user/device prefers. - Locale get platformResolvedLocale => _platformResolvedLocale; - // TODO(flutter_web): Compute the browser locale resolution and set it here. - Locale _platformResolvedLocale; + Locale get platformResolvedLocale; /// A callback that is invoked whenever [locale] changes value. /// diff --git a/engine/src/flutter/lib/web_ui/test/engine/window_test.dart b/engine/src/flutter/lib/web_ui/test/engine/window_test.dart index 4dd29762728..d73fcfdf94e 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/window_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/window_test.dart @@ -4,6 +4,7 @@ // @dart = 2.6 import 'dart:async'; +import 'dart:html' as html; import 'dart:typed_data'; import 'package:test/test.dart'; @@ -222,4 +223,31 @@ void main() { await completer.future; }); + + test('Window implements locale, locales, and locale change notifications', () async { + // This will count how many times we notified about locale changes. + int localeChangedCount = 0; + window.onLocaleChanged = () { + localeChangedCount += 1; + }; + + // Cause DomRenderer to initialize itself. + domRenderer; + + // We populate the initial list of locales automatically (only test that we + // got some locales; some contributors may be in different locales, so we + // can't test the exact contents). + expect(window.locale, isA()); + expect(window.locales, isNotEmpty); + + // Trigger a change notification (reset locales because the notification + // doesn't actually change the list of languages; the test only observes + // that the list is populated again). + window.debugResetLocales(); + expect(window.locales, null); + expect(localeChangedCount, 0); + html.window.dispatchEvent(html.Event('languagechange')); + expect(window.locales, isNotEmpty); + expect(localeChangedCount, 1); + }); }