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);
+ });
}