Commit f2d09601 authored by Yegor's avatar Yegor Committed by GitHub

Internationalize time numerals in the time picker and TimeOfDay (#12166)

* internationalize time numerals

* tests

* use foundation.dart instead of meta.dart

* address comments
parent 441b5c20
......@@ -101,7 +101,7 @@ class _DateTimePicker extends StatelessWidget {
new Expanded(
flex: 3,
child: new _InputDropdown(
valueText: selectedTime.toString(),
valueText: selectedTime.format(context),
valueStyle: valueStyle,
onPressed: () { _selectTime(context); },
),
......
......@@ -182,7 +182,7 @@ class DialogDemoState extends State<DialogDemo> {
if (value != null && value != _selectedTime) {
_selectedTime = value;
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('You selected: $value')
content: new Text('You selected: ${value.format(context)}')
));
}
});
......
......@@ -82,7 +82,7 @@ class DateTimeItem extends StatelessWidget {
},
child: new Row(
children: <Widget>[
new Text('$time'),
new Text('${time.format(context)}'),
const Icon(Icons.arrow_drop_down, color: Colors.black54),
]
)
......
......@@ -82,6 +82,7 @@ export 'src/material/text_form_field.dart';
export 'src/material/text_selection.dart';
export 'src/material/theme.dart';
export 'src/material/theme_data.dart';
export 'src/material/time.dart';
export 'src/material/time_picker.dart';
export 'src/material/toggleable.dart';
export 'src/material/tooltip.dart';
......
......@@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart' as intl;
import 'i18n/localizations.dart';
import 'time.dart';
import 'typography.dart';
/// Defines the localized resource values used by the Material widgets.
......@@ -109,6 +110,17 @@ abstract class MaterialLocalizations {
/// See also: https://material.io/guidelines/style/typography.html
TextTheme get localTextGeometry;
/// Formats [TimeOfDay.hour] in the given time of day according to the value
/// of [timeOfDayFormat].
String formatHour(TimeOfDay timeOfDay);
/// Formats [TimeOfDay.minute] in the given time of day according to the value
/// of [timeOfDayFormat].
String formatMinute(TimeOfDay timeOfDay);
/// Formats [timeOfDay] according to the value of [timeOfDayFormat].
String formatTimeOfDay(TimeOfDay timeOfDay);
/// The `MaterialLocalizations` from the closest [Localizations] instance
/// that encloses the given context.
///
......@@ -133,22 +145,45 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
///
/// [LocalizationsDelegate] implementations typically call the static [load]
/// function, rather than constructing this class directly.
DefaultMaterialLocalizations(this.locale) {
assert(locale != null);
DefaultMaterialLocalizations(this.locale)
: assert(locale != null),
this._localeName = _computeLocaleName(locale) {
if (localizations.containsKey(locale.languageCode))
_nameToValue.addAll(localizations[locale.languageCode]);
if (localizations.containsKey(_localeName))
_nameToValue.addAll(localizations[_localeName]);
if (intl.NumberFormat.localeExists(_localeName)) {
_decimalFormat = new intl.NumberFormat.decimalPattern(_localeName);
_twoDigitZeroPaddedFormat = new intl.NumberFormat('00', _localeName);
} else if (intl.NumberFormat.localeExists(locale.languageCode)) {
_decimalFormat = new intl.NumberFormat.decimalPattern(locale.languageCode);
_twoDigitZeroPaddedFormat = new intl.NumberFormat('00', locale.languageCode);
} else {
_decimalFormat = new intl.NumberFormat.decimalPattern();
_twoDigitZeroPaddedFormat = new intl.NumberFormat('00');
}
}
/// The locale for which the values of this class's localized resources
/// have been translated.
final Locale locale;
final String _localeName;
final Map<String, String> _nameToValue = <String, String>{};
String get _localeName {
/// Formats numbers using variable length format with no zero padding.
///
/// See also [_twoDigitZeroPaddedFormat].
intl.NumberFormat _decimalFormat;
/// Formats numbers as two-digits.
///
/// If the number is less than 10, zero-pads it.
intl.NumberFormat _twoDigitZeroPaddedFormat;
static String _computeLocaleName(Locale locale) {
final String localeName = locale.countryCode.isEmpty ? locale.languageCode : locale.toString();
return intl.Intl.canonicalizedLocale(localeName);
}
......@@ -171,12 +206,67 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
return text;
}
String _formatInteger(int n) {
final String localeName = _localeName;
if (!intl.NumberFormat.localeExists(localeName))
return n.toString();
return new intl.NumberFormat.decimalPattern(localeName).format(n);
@override
String formatHour(TimeOfDay timeOfDay) {
switch (hourFormat(of: timeOfDayFormat)) {
case HourFormat.HH:
return _twoDigitZeroPaddedFormat.format(timeOfDay.hour);
case HourFormat.H:
return formatDecimal(timeOfDay.hour);
case HourFormat.h:
final int hour = timeOfDay.hourOfPeriod;
return formatDecimal(hour == 0 ? 12 : hour);
}
return null;
}
@override
String formatMinute(TimeOfDay timeOfDay) {
return _twoDigitZeroPaddedFormat.format(timeOfDay.minute);
}
/// Formats a [number] using local decimal number format.
///
/// Inserts locale-appropriate thousands separator, if necessary.
String formatDecimal(int number) {
return _decimalFormat.format(number);
}
@override
String formatTimeOfDay(TimeOfDay timeOfDay) {
// Not using intl.DateFormat for two reasons:
//
// - DateFormat supports more formats than our material time picker does,
// and we want to be consistent across time picker format and the string
// formatting of the time of day.
// - DateFormat operates on DateTime, which is sensitive to time eras and
// time zones, while here we want to format hour and minute within one day
// no matter what date the day falls on.
switch (timeOfDayFormat) {
case TimeOfDayFormat.h_colon_mm_space_a:
return '${formatHour(timeOfDay)}:${formatMinute(timeOfDay)} ${_formatDayPeriod(timeOfDay)}';
case TimeOfDayFormat.H_colon_mm:
case TimeOfDayFormat.HH_colon_mm:
return '${formatHour(timeOfDay)}:${formatMinute(timeOfDay)}';
case TimeOfDayFormat.HH_dot_mm:
return '${formatHour(timeOfDay)}.${formatMinute(timeOfDay)}';
case TimeOfDayFormat.a_space_h_colon_mm:
return '${_formatDayPeriod(timeOfDay)} ${formatHour(timeOfDay)}:${formatMinute(timeOfDay)}';
case TimeOfDayFormat.frenchCanadian:
return '${formatHour(timeOfDay)} h ${formatMinute(timeOfDay)}';
}
return null;
}
String _formatDayPeriod(TimeOfDay timeOfDay) {
switch (timeOfDay.period) {
case DayPeriod.am:
return anteMeridiemAbbreviation;
case DayPeriod.pm:
return postMeridiemAbbreviation;
}
return null;
}
@override
......@@ -219,9 +309,9 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
assert(text != null, 'A $locale localization was not found for pageRowsInfoTitle or pageRowsInfoTitleApproximate');
// TODO(hansmuller): this could be more efficient.
return text
.replaceFirst(r'$firstRow', _formatInteger(firstRow))
.replaceFirst(r'$lastRow', _formatInteger(lastRow))
.replaceFirst(r'$rowCount', _formatInteger(rowCount));
.replaceFirst(r'$firstRow', formatDecimal(firstRow))
.replaceFirst(r'$lastRow', formatDecimal(lastRow))
.replaceFirst(r'$rowCount', formatDecimal(rowCount));
}
@override
......@@ -230,7 +320,7 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
@override
String selectedRowCountTitle(int selectedRowCount) {
return _nameToPluralValue(selectedRowCount, 'selectedRowCountTitle') // asserts on no match
.replaceFirst(r'$selectedRowCount', _formatInteger(selectedRowCount));
.replaceFirst(r'$selectedRowCount', formatDecimal(selectedRowCount));
}
@override
......@@ -325,55 +415,3 @@ const Map<String, TimeOfDayFormat> _icuTimeOfDayToEnum = const <String, TimeOfDa
'a h:mm': TimeOfDayFormat.a_space_h_colon_mm,
'ah:mm': TimeOfDayFormat.a_space_h_colon_mm,
};
/// Determines how the time picker invoked using [showTimePicker] formats and
/// lays out the time controls.
///
/// The time picker provides layout configurations optimized for each of the
/// enum values.
enum TimeOfDayFormat {
/// Corresponds to the ICU 'HH:mm' pattern.
///
/// This format uses 24-hour two-digit zero-padded hours. Controls are always
/// laid out horizontally. Hours are separated from minutes by one colon
/// character.
HH_colon_mm,
/// Corresponds to the ICU 'HH.mm' pattern.
///
/// This format uses 24-hour two-digit zero-padded hours. Controls are always
/// laid out horizontally. Hours are separated from minutes by one dot
/// character.
HH_dot_mm,
/// Corresponds to the ICU "HH 'h' mm" pattern used in Canadian French.
///
/// This format uses 24-hour two-digit zero-padded hours. Controls are always
/// laid out horizontally. Hours are separated from minutes by letter 'h'.
frenchCanadian,
/// Corresponds to the ICU 'H:mm' pattern.
///
/// This format uses 24-hour non-padded variable-length hours. Controls are
/// always laid out horizontally. Hours are separated from minutes by one
/// colon character.
H_colon_mm,
/// Corresponds to the ICU 'h:mm a' pattern.
///
/// This format uses 12-hour non-padded variable-length hours with a day
/// period. Controls are laid out horizontally in portrait mode. In landscape
/// mode, the day period appears vertically after (consistent with the ambient
/// [TextDirection]) hour-minute indicator. Hours are separated from minutes
/// by one colon character.
h_colon_mm_space_a,
/// Corresponds to the ICU 'a h:mm' pattern.
///
/// This format uses 12-hour non-padded variable-length hours with a day
/// period. Controls are laid out horizontally in portrait mode. In landscape
/// mode, the day period appears vertically before (consistent with the
/// ambient [TextDirection]) hour-minute indicator. Hours are separated from
/// minutes by one colon character.
a_space_h_colon_mm,
}
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' show hashValues;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'material_localizations.dart';
/// Whether the [TimeOfDay] is before or after noon.
enum DayPeriod {
/// Ante meridiem (before noon).
am,
/// Post meridiem (after noon).
pm,
}
/// A value representing a time during the day, independent of the date that
/// day might fall on or the time zone.
///
/// The time is represented by [hour] and [minute] pair.
///
/// See also:
///
/// * [showTimePicker], which returns this type.
/// * [MaterialLocalizations], which provides methods for formatting values of
/// this type according to the chosen [Locale].
/// * [DateTime], which represents date and time, and is subject to eras and
/// time zones.
@immutable
class TimeOfDay {
/// The number of hours in one day, i.e. 24.
static const int hoursPerDay = 24;
/// The number of hours in one day period (see also [DayPeriod]), i.e. 12.
static const int hoursPerPeriod = 12;
/// The number of minutes in one hour, i.e. 60.
static const int minutesPerHour = 60;
/// Creates a time of day.
///
/// The [hour] argument must be between 0 and 23, inclusive. The [minute]
/// argument must be between 0 and 59, inclusive.
const TimeOfDay({ @required this.hour, @required this.minute });
/// Creates a time of day based on the given time.
///
/// The [hour] is set to the time's hour and the [minute] is set to the time's
/// minute in the timezone of the given [DateTime].
TimeOfDay.fromDateTime(DateTime time) : hour = time.hour, minute = time.minute;
/// Creates a time of day based on the current time.
///
/// The [hour] is set to the current hour and the [minute] is set to the
/// current minute in the local time zone.
factory TimeOfDay.now() { return new TimeOfDay.fromDateTime(new DateTime.now()); }
/// Returns a new TimeOfDay with the hour and/or minute replaced.
TimeOfDay replacing({ int hour, int minute }) {
assert(hour == null || (hour >= 0 && hour < hoursPerDay));
assert(minute == null || (minute >= 0 && minute < minutesPerHour));
return new TimeOfDay(hour: hour ?? this.hour, minute: minute ?? this.minute);
}
/// The selected hour, in 24 hour time from 0..23.
final int hour;
/// The selected minute.
final int minute;
/// Whether this time of day is before or after noon.
DayPeriod get period => hour < hoursPerPeriod ? DayPeriod.am : DayPeriod.pm;
/// Which hour of the current period (e.g., am or pm) this time is.
int get hourOfPeriod => hour - periodOffset;
/// The hour at which the current period starts.
int get periodOffset => period == DayPeriod.am ? 0 : hoursPerPeriod;
/// Returns the localized string representation of this time of day.
///
/// This is a shortcut for [MaterialLocalizations.formatTimeOfDay].
String format(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
return localizations.formatTimeOfDay(this);
}
@override
bool operator ==(dynamic other) {
if (other is! TimeOfDay)
return false;
final TimeOfDay typedOther = other;
return typedOther.hour == hour
&& typedOther.minute == minute;
}
@override
int get hashCode => hashValues(hour, minute);
@override
String toString() {
String _addLeadingZeroIfNeeded(int value) {
if (value < 10)
return '0$value';
return value.toString();
}
final String hourLabel = _addLeadingZeroIfNeeded(hour);
final String minuteLabel = _addLeadingZeroIfNeeded(minute);
return '$TimeOfDay($hourLabel:$minuteLabel)';
}
}
/// Determines how the time picker invoked using [showTimePicker] formats and
/// lays out the time controls.
///
/// The time picker provides layout configurations optimized for each of the
/// enum values.
enum TimeOfDayFormat {
/// Corresponds to the ICU 'HH:mm' pattern.
///
/// This format uses 24-hour two-digit zero-padded hours. Controls are always
/// laid out horizontally. Hours are separated from minutes by one colon
/// character.
HH_colon_mm,
/// Corresponds to the ICU 'HH.mm' pattern.
///
/// This format uses 24-hour two-digit zero-padded hours. Controls are always
/// laid out horizontally. Hours are separated from minutes by one dot
/// character.
HH_dot_mm,
/// Corresponds to the ICU "HH 'h' mm" pattern used in Canadian French.
///
/// This format uses 24-hour two-digit zero-padded hours. Controls are always
/// laid out horizontally. Hours are separated from minutes by letter 'h'.
frenchCanadian,
/// Corresponds to the ICU 'H:mm' pattern.
///
/// This format uses 24-hour non-padded variable-length hours. Controls are
/// always laid out horizontally. Hours are separated from minutes by one
/// colon character.
H_colon_mm,
/// Corresponds to the ICU 'h:mm a' pattern.
///
/// This format uses 12-hour non-padded variable-length hours with a day
/// period. Controls are laid out horizontally in portrait mode. In landscape
/// mode, the day period appears vertically after (consistent with the ambient
/// [TextDirection]) hour-minute indicator. Hours are separated from minutes
/// by one colon character.
h_colon_mm_space_a,
/// Corresponds to the ICU 'a h:mm' pattern.
///
/// This format uses 12-hour non-padded variable-length hours with a day
/// period. Controls are laid out horizontally in portrait mode. In landscape
/// mode, the day period appears vertically before (consistent with the
/// ambient [TextDirection]) hour-minute indicator. Hours are separated from
/// minutes by one colon character.
a_space_h_colon_mm,
}
/// Describes how hours are formatted.
enum HourFormat {
/// Zero-padded two-digit 24-hour format ranging from "00" to "23".
HH,
/// Non-padded variable-length 24-hour format ranging from "0" to "23".
H,
/// Non-padded variable-length hour in day period format ranging from "1" to
/// "12".
h,
}
/// The [HourFormat] used for the given [TimeOfDayFormat].
HourFormat hourFormat({ @required TimeOfDayFormat of }) {
switch (of) {
case TimeOfDayFormat.h_colon_mm_space_a:
case TimeOfDayFormat.a_space_h_colon_mm:
return HourFormat.h;
case TimeOfDayFormat.H_colon_mm:
return HourFormat.H;
case TimeOfDayFormat.HH_dot_mm:
case TimeOfDayFormat.HH_colon_mm:
case TimeOfDayFormat.frenchCanadian:
return HourFormat.HH;
}
return null;
}
......@@ -330,7 +330,7 @@ class Localizations extends StatefulWidget {
/// Overrides the inherited [Locale] or [LocalizationsDelegate]s for `child`.
///
/// This factory constructor is used for the (usually rare) situtation where part
/// This factory constructor is used for the (usually rare) situation where part
/// of an app should be localized for a different locale than the one defined
/// for the device, or if its localizations should come from a different list
/// of [LocalizationsDelegate]s than the list defined by
......
......@@ -32,6 +32,119 @@ void main() {
final LocalizationTrackerState innerTracker = tester.state(find.byKey(const ValueKey<String>('inner')));
expect(innerTracker.captionFontSize, 13.0);
});
group(DefaultMaterialLocalizations, () {
test('uses exact locale when exists', () {
final DefaultMaterialLocalizations localizations = new DefaultMaterialLocalizations(const Locale('pt', 'PT'));
expect(localizations.formatDecimal(10000), '10\u00A0000');
});
test('falls back to language code when exact locale is missing', () {
final DefaultMaterialLocalizations localizations = new DefaultMaterialLocalizations(const Locale('pt', 'XX'));
expect(localizations.formatDecimal(10000), '10.000');
});
test('falls back to default format when neither language code nor exact locale are available', () {
final DefaultMaterialLocalizations localizations = new DefaultMaterialLocalizations(const Locale('xx', 'XX'));
expect(localizations.formatDecimal(10000), '10,000');
});
group('formatHour', () {
test('formats h', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('en', 'US'));
expect(localizations.formatHour(const TimeOfDay(hour: 10, minute: 0)), '10');
expect(localizations.formatHour(const TimeOfDay(hour: 20, minute: 0)), '8');
localizations = new DefaultMaterialLocalizations(const Locale('ar', ''));
expect(localizations.formatHour(const TimeOfDay(hour: 10, minute: 0)), '١٠');
expect(localizations.formatHour(const TimeOfDay(hour: 20, minute: 0)), '٨');
});
test('formats HH', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('de', ''));
expect(localizations.formatHour(const TimeOfDay(hour: 9, minute: 0)), '09');
expect(localizations.formatHour(const TimeOfDay(hour: 20, minute: 0)), '20');
localizations = new DefaultMaterialLocalizations(const Locale('en', 'GB'));
expect(localizations.formatHour(const TimeOfDay(hour: 9, minute: 0)), '09');
expect(localizations.formatHour(const TimeOfDay(hour: 20, minute: 0)), '20');
});
test('formats H', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('es', ''));
expect(localizations.formatHour(const TimeOfDay(hour: 9, minute: 0)), '9');
expect(localizations.formatHour(const TimeOfDay(hour: 20, minute: 0)), '20');
localizations = new DefaultMaterialLocalizations(const Locale('fa', ''));
expect(localizations.formatHour(const TimeOfDay(hour: 9, minute: 0)), '۹');
expect(localizations.formatHour(const TimeOfDay(hour: 20, minute: 0)), '۲۰');
});
});
group('formatMinute', () {
test('formats English', () {
final DefaultMaterialLocalizations localizations = new DefaultMaterialLocalizations(const Locale('en', 'US'));
expect(localizations.formatMinute(const TimeOfDay(hour: 1, minute: 32)), '32');
});
test('formats Arabic', () {
final DefaultMaterialLocalizations localizations = new DefaultMaterialLocalizations(const Locale('ar', ''));
expect(localizations.formatMinute(const TimeOfDay(hour: 1, minute: 32)), '٣٢');
});
});
group('formatTimeOfDay', () {
test('formats ${TimeOfDayFormat.h_colon_mm_space_a}', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('ar', ''));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '٩:٣٢ ص');
localizations = new DefaultMaterialLocalizations(const Locale('en', ''));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '9:32 AM');
});
test('formats ${TimeOfDayFormat.HH_colon_mm}', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('de', ''));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '09:32');
localizations = new DefaultMaterialLocalizations(const Locale('en', 'ZA'));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '09:32');
});
test('formats ${TimeOfDayFormat.H_colon_mm}', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('es', ''));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '9:32');
localizations = new DefaultMaterialLocalizations(const Locale('ja', ''));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '9:32');
});
test('formats ${TimeOfDayFormat.frenchCanadian}', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('fr', 'CA'));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '09 h 32');
});
test('formats ${TimeOfDayFormat.a_space_h_colon_mm}', () {
DefaultMaterialLocalizations localizations;
localizations = new DefaultMaterialLocalizations(const Locale('zh', ''));
expect(localizations.formatTimeOfDay(const TimeOfDay(hour: 9, minute: 32)), '上午 9:32');
});
});
});
}
class LocalizationTracker extends StatefulWidget {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment