mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Implement local, locales, and onLocaleChanged for the web (flutter/engine#18137)
* implement `locale`, `locales`, and `onLocaleChanged` in the web version of `Window. Co-authored-by: Simon Lightfoot <simon@devangels.london>
This commit is contained in:
parent
4ddcf89a1d
commit
c04d3231da
@ -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<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 @@ class DomRenderer {
|
||||
|
||||
registerHotRestartListener(() {
|
||||
_resizeSubscription?.cancel();
|
||||
_localeSubscription?.cancel();
|
||||
_staleHotRestartState.addAll(<html.Element>[
|
||||
_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();
|
||||
}
|
||||
|
||||
@ -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<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 @@ 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<ui.FrameTiming> timings) {
|
||||
_invoke1<List<ui.FrameTiming>>(_onReportTimings, _onReportTimingsZone, timings);
|
||||
_invoke1<List<ui.FrameTiming>>(
|
||||
_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<ui.PointerDataPacket>(_onPointerDataPacket, _onPointerDataPacketZone, packet);
|
||||
_invoke1<ui.PointerDataPacket>(
|
||||
_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<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 @@ 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<String, ByteData, ui.PlatformMessageResponseCallback>(
|
||||
_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<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 @@ 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<A>(void callback(A a), Zone zone, A arg) {
|
||||
if (callback == null)
|
||||
if (callback == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(zone != null);
|
||||
|
||||
@ -659,9 +734,11 @@ void _invoke1<A>(void callback(A a), Zone zone, A arg) {
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
|
||||
@ -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<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.
|
||||
///
|
||||
|
||||
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user