Implement `local`, `locales`, and `onLocaleChanged` for the web (#18137)

* implement `locale`, `locales`, and `onLocaleChanged` in the web version of `Window.

Co-authored-by: Simon Lightfoot <simon@devangels.london>
diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart
index 9d4101e..a2d03c0 100644
--- a/lib/web_ui/lib/src/engine/dom_renderer.dart
+++ b/lib/web_ui/lib/src/engine/dom_renderer.dart
@@ -27,9 +27,16 @@
   static const int vibrateHeavyImpact = 30;
   static const int vibrateSelectionClick = 10;
 
+  /// Fires when browser language preferences change.
+  static const html.EventStreamProvider<html.Event> languageChangeEvent =
+      const html.EventStreamProvider<html.Event>('languagechange');
+
   /// Listens to window resize events.
   StreamSubscription<html.Event> _resizeSubscription;
 
+  /// Listens to window locale events.
+  StreamSubscription<html.Event> _localeSubscription;
+
   /// Contains Flutter-specific CSS rules, such as default margins and
   /// paddings.
   html.StyleElement _styleElement;
@@ -85,6 +92,7 @@
 
     registerHotRestartListener(() {
       _resizeSubscription?.cancel();
+      _localeSubscription?.cancel();
       _staleHotRestartState.addAll(<html.Element>[
         _glassPaneElement,
         _styleElement,
@@ -462,6 +470,9 @@
     } 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 @@
     }
   }
 
+  /// 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/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart
index 819ecd6..9d8afd9 100644
--- a/lib/web_ui/lib/src/engine/window.dart
+++ b/lib/web_ui/lib/src/engine/window.dart
@@ -181,7 +181,8 @@
   }
 
   @override
-  ui.VoidCallback get onPlatformBrightnessChanged => _onPlatformBrightnessChanged;
+  ui.VoidCallback get onPlatformBrightnessChanged =>
+      _onPlatformBrightnessChanged;
   ui.VoidCallback _onPlatformBrightnessChanged;
   Zone _onPlatformBrightnessChangedZone;
   @override
@@ -224,6 +225,64 @@
     _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<ui.Locale> get locales => _locales;
+  List<ui.Locale> _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<ui.Locale> 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<ui.Locale> locales = <ui.Locale>[];
+    for (final String language in html.window.navigator.languages) {
+      final List<String> 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 @@
   /// Engine code should use this method instead of the callback directly.
   /// Otherwise zones won't work properly.
   void invokeOnReportTimings(List<ui.FrameTiming> timings) {
-    _invoke1<List<ui.FrameTiming>>(_onReportTimings, _onReportTimingsZone, timings);
+    _invoke1<List<ui.FrameTiming>>(
+        _onReportTimings, _onReportTimingsZone, timings);
   }
 
   @override
@@ -291,7 +351,8 @@
   /// Engine code should use this method instead of the callback directly.
   /// Otherwise zones won't work properly.
   void invokeOnPointerDataPacket(ui.PointerDataPacket packet) {
-    _invoke1<ui.PointerDataPacket>(_onPointerDataPacket, _onPointerDataPacketZone, packet);
+    _invoke1<ui.PointerDataPacket>(
+        _onPointerDataPacket, _onPointerDataPacketZone, packet);
   }
 
   @override
@@ -322,13 +383,15 @@
 
   /// 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<int, ui.SemanticsAction, ByteData>(_onSemanticsAction,
-        _onSemanticsActionZone, id, action, args);
+  void invokeOnSemanticsAction(
+      int id, ui.SemanticsAction action, ByteData args) {
+    _invoke3<int, ui.SemanticsAction, ByteData>(
+        _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 @@
   /// 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 @@
 
   /// 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<String, ByteData, ui.PlatformMessageResponseCallback>(
       _onPlatformMessage,
       _onPlatformMessageZone,
@@ -371,14 +436,16 @@
     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 @@
           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<String, dynamic> 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<dynamic> arguments = decoded.arguments;
@@ -451,7 +520,8 @@
             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 @@
 
       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 @@
           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 @@
 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 @@
 
 /// 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 @@
 
 /// Invokes [callback] inside the given [zone] passing it [arg].
 void _invoke1<A>(void callback(A a), Zone zone, A arg) {
-  if (callback == null)
+  if (callback == null) {
     return;
+  }
 
   assert(zone != null);
 
@@ -659,9 +734,11 @@
 }
 
 /// Invokes [callback] inside the given [zone] passing it [arg1], [arg2], and [arg3].
-void _invoke3<A1, A2, A3>(void callback(A1 a1, A2 a2, A3 a3), Zone zone, A1 arg1, A2 arg2, A3 arg3) {
-  if (callback == null)
+void _invoke3<A1, A2, A3>(
+    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/lib/web_ui/lib/src/ui/window.dart b/lib/web_ui/lib/src/ui/window.dart
index 54c2b33..90f6f44 100644
--- a/lib/web_ui/lib/src/ui/window.dart
+++ b/lib/web_ui/lib/src/ui/window.dart
@@ -668,8 +668,6 @@
   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 @@
   ///
   /// 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 @@
   ///
   ///  * [WidgetsBindingObserver], for a mechanism at the widgets layer to
   ///    observe when this value changes.
-  List<Locale> get locales => _locales;
-  // TODO(flutter_web): Get the real locale from the browser.
-  List<Locale> _locales = const [_enUS];
+  List<Locale> 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/lib/web_ui/test/engine/window_test.dart b/lib/web_ui/test/engine/window_test.dart
index 4dd2976..d73fcfd 100644
--- a/lib/web_ui/test/engine/window_test.dart
+++ b/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 @@
 
     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<ui.Locale>());
+    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);
+  });
 }