From f2d096010defe90dc9c735491ec92d67912dea3c Mon Sep 17 00:00:00 2001 From: Yegor Date: Fri, 22 Sep 2017 14:35:25 -0700 Subject: [PATCH] Internationalize time numerals in the time picker and TimeOfDay (#12166) * internationalize time numerals * tests * use foundation.dart instead of meta.dart * address comments --- .../material/date_and_time_picker_demo.dart | 2 +- .../lib/demo/material/dialog_demo.dart | 2 +- .../material/full_screen_dialog_demo.dart | 2 +- packages/flutter/lib/material.dart | 1 + .../src/material/material_localizations.dart | 168 ++++++----- packages/flutter/lib/src/material/time.dart | 198 +++++++++++++ .../flutter/lib/src/material/time_picker.dart | 271 ++++++------------ .../lib/src/widgets/localizations.dart | 2 +- .../material/material_localizations_test.dart | 113 ++++++++ 9 files changed, 512 insertions(+), 247 deletions(-) create mode 100644 packages/flutter/lib/src/material/time.dart diff --git a/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart b/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart index 7d8587738e6..59187bf939e 100644 --- a/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart @@ -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); }, ), diff --git a/examples/flutter_gallery/lib/demo/material/dialog_demo.dart b/examples/flutter_gallery/lib/demo/material/dialog_demo.dart index 3cb06fef65b..506dc3e36bc 100644 --- a/examples/flutter_gallery/lib/demo/material/dialog_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/dialog_demo.dart @@ -182,7 +182,7 @@ class DialogDemoState extends State { 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)}') )); } }); diff --git a/examples/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart b/examples/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart index e2876e3f74c..2131dd113c8 100644 --- a/examples/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/full_screen_dialog_demo.dart @@ -82,7 +82,7 @@ class DateTimeItem extends StatelessWidget { }, child: new Row( children: [ - new Text('$time'), + new Text('${time.format(context)}'), const Icon(Icons.arrow_drop_down, color: Colors.black54), ] ) diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index a7eb18458c1..2c4730425ad 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -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'; diff --git a/packages/flutter/lib/src/material/material_localizations.dart b/packages/flutter/lib/src/material/material_localizations.dart index 650cf1eccac..046fa2084fa 100644 --- a/packages/flutter/lib/src/material/material_localizations.dart +++ b/packages/flutter/lib/src/material/material_localizations.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 _nameToValue = {}; - 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 _icuTimeOfDayToEnum = const = 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; +} diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index 1a9fbc8ee2a..959f30ba794 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -17,103 +17,13 @@ import 'feedback.dart'; import 'flat_button.dart'; import 'material_localizations.dart'; import 'theme.dart'; +import 'time.dart'; import 'typography.dart'; const Duration _kDialAnimateDuration = const Duration(milliseconds: 200); const double _kTwoPi = 2 * math.PI; -const int _kHoursPerDay = 24; -const int _kHoursPerPeriod = 12; -const int _kMinutesPerHour = 60; const Duration _kVibrateCommitDelay = const Duration(milliseconds: 100); -/// 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. -@immutable -class TimeOfDay { - /// 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 < _kHoursPerDay)); - assert(minute == null || (minute >= 0 && minute < _kMinutesPerHour)); - 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 < _kHoursPerPeriod ? DayPeriod.am : DayPeriod.pm; - - /// Which hour of the current period (e.g., am or pm) this time is. - int get hourOfPeriod => hour - periodOffset; - - String _addLeadingZeroIfNeeded(int value) { - if (value < 10) - return '0$value'; - return value.toString(); - } - - /// A string representing the hour, in 24 hour time (e.g., '04' or '18'). - String get hourLabel => _addLeadingZeroIfNeeded(hour); - - /// A string representing the minute (e.g., '07'). - String get minuteLabel => _addLeadingZeroIfNeeded(minute); - - /// A string representing the hour of the current period (e.g., '4' or '6'). - String get hourOfPeriodLabel { - final int hourOfPeriod = this.hourOfPeriod; - if (hourOfPeriod == 0) - return '12'; - return hourOfPeriod.toString(); - } - - /// The hour at which the current period starts. - int get periodOffset => period == DayPeriod.am ? 0 : _kHoursPerPeriod; - - @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() => '$hourLabel:$minuteLabel'; -} - enum _TimePickerMode { hour, minute } const double _kTimePickerHeaderPortraitHeight = 96.0; @@ -181,19 +91,6 @@ class _TimePickerFragmentContext { final ValueChanged<_TimePickerMode> onModeChange; } -/// Describes how hours are formatted. -enum _TimePickerHourFormat { - /// 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, -} - /// Contains the [widget] and layout properties of an atom of time information, /// such as am/pm indicator, hour, minute and string literals appearing in the /// formatted time string. @@ -287,7 +184,7 @@ class _DayPeriodControl extends StatelessWidget { final _TimePickerFragmentContext fragmentContext; void _handleChangeDayPeriod() { - final int newHour = (fragmentContext.selectedTime.hour + _kHoursPerPeriod) % _kHoursPerDay; + final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; fragmentContext.onTimeChange(fragmentContext.selectedTime.replacing(hour: newHour)); } @@ -331,32 +228,20 @@ class _HourControl extends StatelessWidget { }); final _TimePickerFragmentContext fragmentContext; - final _TimePickerHourFormat hourFormat; + final HourFormat hourFormat; @override Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour ? fragmentContext.activeStyle : fragmentContext.inactiveStyle; return new GestureDetector( onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context), - child: new Text(_formatHour(), style: hourStyle), + child: new Text(localizations.formatHour(fragmentContext.selectedTime), style: hourStyle), ); } - - String _formatHour() { - assert(hourFormat != null); - switch (hourFormat) { - case _TimePickerHourFormat.HH: - return fragmentContext.selectedTime.hourLabel; - case _TimePickerHourFormat.H: - return fragmentContext.selectedTime.hour.toString(); - case _TimePickerHourFormat.h: - return fragmentContext.selectedTime.hourOfPeriodLabel; - } - return null; - } } /// A passive fragment showing a string value. @@ -387,40 +272,25 @@ class _MinuteControl extends StatelessWidget { @override Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); final TextStyle minuteStyle = fragmentContext.mode == _TimePickerMode.minute ? fragmentContext.activeStyle : fragmentContext.inactiveStyle; return new GestureDetector( onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context), - child: new Text(fragmentContext.selectedTime.minuteLabel, style: minuteStyle), + child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle), ); } } -_TimePickerHourFormat _getHourFormat(TimeOfDayFormat format) { - switch (format) { - case TimeOfDayFormat.h_colon_mm_space_a: - case TimeOfDayFormat.a_space_h_colon_mm: - return _TimePickerHourFormat.h; - case TimeOfDayFormat.H_colon_mm: - return _TimePickerHourFormat.H; - case TimeOfDayFormat.HH_dot_mm: - case TimeOfDayFormat.HH_colon_mm: - case TimeOfDayFormat.frenchCanadian: - return _TimePickerHourFormat.HH; - } - - return null; -} - /// Provides time picker header layout configuration for the given /// [timeOfDayFormat] passing [context] to each widget in the configuration. /// /// [timeOfDayFormat] and [context] must not be `null`. _TimePickerHeaderFormat _buildHeaderFormat(TimeOfDayFormat timeOfDayFormat, _TimePickerFragmentContext context) { // Creates an hour fragment. - _TimePickerHeaderFragment hour(_TimePickerHourFormat hourFormat) { + _TimePickerHeaderFragment hour(HourFormat hourFormat) { return new _TimePickerHeaderFragment( layoutId: _TimePickerHeaderId.hour, widget: new _HourControl(fragmentContext: context, hourFormat: hourFormat), @@ -494,7 +364,7 @@ _TimePickerHeaderFormat _buildHeaderFormat(TimeOfDayFormat timeOfDayFormat, _Tim 0, piece( pivotIndex: 1, - fragment1: hour(_TimePickerHourFormat.h), + fragment1: hour(HourFormat.h), fragment2: string(_TimePickerHeaderId.colon, ':'), fragment3: minute(), ), @@ -506,14 +376,14 @@ _TimePickerHeaderFormat _buildHeaderFormat(TimeOfDayFormat timeOfDayFormat, _Tim case TimeOfDayFormat.H_colon_mm: return format(0, piece( pivotIndex: 1, - fragment1: hour(_TimePickerHourFormat.H), + fragment1: hour(HourFormat.H), fragment2: string(_TimePickerHeaderId.colon, ':'), fragment3: minute(), )); case TimeOfDayFormat.HH_dot_mm: return format(0, piece( pivotIndex: 1, - fragment1: hour(_TimePickerHourFormat.HH), + fragment1: hour(HourFormat.HH), fragment2: string(_TimePickerHeaderId.dot, '.'), fragment3: minute(), )); @@ -526,7 +396,7 @@ _TimePickerHeaderFormat _buildHeaderFormat(TimeOfDayFormat timeOfDayFormat, _Tim ), piece( pivotIndex: 1, - fragment1: hour(_TimePickerHourFormat.h), + fragment1: hour(HourFormat.h), fragment2: string(_TimePickerHeaderId.colon, ':'), fragment3: minute(), ), @@ -534,14 +404,14 @@ _TimePickerHeaderFormat _buildHeaderFormat(TimeOfDayFormat timeOfDayFormat, _Tim case TimeOfDayFormat.frenchCanadian: return format(0, piece( pivotIndex: 1, - fragment1: hour(_TimePickerHourFormat.HH), + fragment1: hour(HourFormat.HH), fragment2: string(_TimePickerHeaderId.hString, 'h'), fragment3: minute(), )); case TimeOfDayFormat.HH_colon_mm: return format(0, piece( pivotIndex: 1, - fragment1: hour(_TimePickerHourFormat.HH), + fragment1: hour(HourFormat.HH), fragment2: string(_TimePickerHeaderId.colon, ':'), fragment3: minute(), )); @@ -801,26 +671,65 @@ enum _DialRing { inner, } -List _initHours(TextTheme textTheme, _DialRing ring, bool is24h) { - const List amHours = const [ - '12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11' - ]; - const List pmHours = const [ - '00', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23' - ]; - switch (ring) { - case _DialRing.outer: - return _initPainters(textTheme, is24h ? pmHours : amHours); - case _DialRing.inner: - return is24h ? _initPainters(textTheme, amHours) : null; - } - return null; +const List _amHours = const [ + const TimeOfDay(hour: 0, minute: 0), + const TimeOfDay(hour: 1, minute: 0), + const TimeOfDay(hour: 2, minute: 0), + const TimeOfDay(hour: 3, minute: 0), + const TimeOfDay(hour: 4, minute: 0), + const TimeOfDay(hour: 5, minute: 0), + const TimeOfDay(hour: 6, minute: 0), + const TimeOfDay(hour: 7, minute: 0), + const TimeOfDay(hour: 8, minute: 0), + const TimeOfDay(hour: 9, minute: 0), + const TimeOfDay(hour: 10, minute: 0), + const TimeOfDay(hour: 11, minute: 0), +]; + +const List _pmHours = const [ + const TimeOfDay(hour: 12, minute: 0), + const TimeOfDay(hour: 13, minute: 0), + const TimeOfDay(hour: 14, minute: 0), + const TimeOfDay(hour: 15, minute: 0), + const TimeOfDay(hour: 16, minute: 0), + const TimeOfDay(hour: 17, minute: 0), + const TimeOfDay(hour: 18, minute: 0), + const TimeOfDay(hour: 19, minute: 0), + const TimeOfDay(hour: 20, minute: 0), + const TimeOfDay(hour: 21, minute: 0), + const TimeOfDay(hour: 22, minute: 0), + const TimeOfDay(hour: 23, minute: 0), +]; + +List _init24HourInnerRing(TextTheme textTheme, MaterialLocalizations localizations) { + return _initPainters(textTheme, _amHours.map(localizations.formatHour).toList()); } -List _initMinutes(TextTheme textTheme) { - return _initPainters(textTheme, [ - '00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55' - ]); +List _init24HourOuterRing(TextTheme textTheme, MaterialLocalizations localizations) { + return _initPainters(textTheme, _pmHours.map(localizations.formatHour).toList()); +} + +List _init12HourOuterRing(TextTheme textTheme, MaterialLocalizations localizations) { + return _initPainters(textTheme, _amHours.map(localizations.formatHour).toList()); +} + +const List _minuteMarkerValues = const [ + const TimeOfDay(hour: 0, minute: 0), + const TimeOfDay(hour: 0, minute: 5), + const TimeOfDay(hour: 0, minute: 10), + const TimeOfDay(hour: 0, minute: 15), + const TimeOfDay(hour: 0, minute: 20), + const TimeOfDay(hour: 0, minute: 25), + const TimeOfDay(hour: 0, minute: 30), + const TimeOfDay(hour: 0, minute: 35), + const TimeOfDay(hour: 0, minute: 40), + const TimeOfDay(hour: 0, minute: 45), + const TimeOfDay(hour: 0, minute: 50), + const TimeOfDay(hour: 0, minute: 55), +]; + +List _initMinutes(TextTheme textTheme, MaterialLocalizations localizations) { + return _initPainters(textTheme, _minuteMarkerValues.map(localizations.formatMinute).toList()); } class _DialPainter extends CustomPainter { @@ -992,21 +901,21 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { double _getThetaForTime(TimeOfDay time) { final double fraction = (widget.mode == _TimePickerMode.hour) ? - (time.hour / _kHoursPerPeriod) % _kHoursPerPeriod : - (time.minute / _kMinutesPerHour) % _kMinutesPerHour; + (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod : + (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; return (math.PI / 2.0 - fraction * _kTwoPi) % _kTwoPi; } TimeOfDay _getTimeForTheta(double theta) { final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; if (widget.mode == _TimePickerMode.hour) { - int newHour = (fraction * _kHoursPerPeriod).round() % _kHoursPerPeriod; + int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; if (widget.is24h) { if (_activeRing == _DialRing.outer) { if (newHour != 0) - newHour = (newHour + _kHoursPerPeriod) % _kHoursPerDay; + newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; } else if (newHour == 0) { - newHour = _kHoursPerPeriod; + newHour = TimeOfDay.hoursPerPeriod; } } else { newHour = newHour + widget.selectedTime.periodOffset; @@ -1014,7 +923,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { return widget.selectedTime.replacing(hour: newHour); } else { return widget.selectedTime.replacing( - minute: (fraction * _kMinutesPerHour).round() % _kMinutesPerHour + minute: (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour ); } } @@ -1076,6 +985,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); Color backgroundColor; switch (themeData.brightness) { @@ -1094,15 +1004,20 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { List secondaryInnerLabels; switch (widget.mode) { case _TimePickerMode.hour: - primaryOuterLabels = _initHours(theme.textTheme, _DialRing.outer, widget.is24h); - secondaryOuterLabels = _initHours(theme.accentTextTheme, _DialRing.outer, widget.is24h); - primaryInnerLabels = _initHours(theme.textTheme, _DialRing.inner, widget.is24h); - secondaryInnerLabels = _initHours(theme.accentTextTheme, _DialRing.inner, widget.is24h); + if (widget.is24h) { + primaryOuterLabels = _init24HourOuterRing(theme.textTheme, localizations); + secondaryOuterLabels = _init24HourOuterRing(theme.accentTextTheme, localizations); + primaryInnerLabels = _init24HourInnerRing(theme.textTheme, localizations); + secondaryInnerLabels = _init24HourInnerRing(theme.accentTextTheme, localizations); + } else { + primaryOuterLabels = _init12HourOuterRing(theme.textTheme, localizations); + secondaryOuterLabels = _init12HourOuterRing(theme.accentTextTheme, localizations); + } break; case _TimePickerMode.minute: - primaryOuterLabels = _initMinutes(theme.textTheme); + primaryOuterLabels = _initMinutes(theme.textTheme, localizations); primaryInnerLabels = null; - secondaryOuterLabels = _initMinutes(theme.accentTextTheme); + secondaryOuterLabels = _initMinutes(theme.accentTextTheme, localizations); secondaryInnerLabels = null; break; } @@ -1191,7 +1106,8 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { @override Widget build(BuildContext context) { - final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat; final Widget picker = new Padding( padding: const EdgeInsets.all(16.0), @@ -1199,14 +1115,13 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { aspectRatio: 1.0, child: new _Dial( mode: _mode, - is24h: _getHourFormat(timeOfDayFormat) != _TimePickerHourFormat.h, + is24h: hourFormat(of: timeOfDayFormat) != HourFormat.h, selectedTime: _selectedTime, onChanged: _handleTimeChanged, ) ) ); - final MaterialLocalizations localizations = MaterialLocalizations.of(context); final Widget actions = new ButtonTheme.bar( child: new ButtonBar( children: [ @@ -1309,6 +1224,6 @@ Future showTimePicker({ assert(initialTime != null); return await showDialog( context: context, - child: new _TimePickerDialog(initialTime: initialTime) + child: new _TimePickerDialog(initialTime: initialTime), ); } diff --git a/packages/flutter/lib/src/widgets/localizations.dart b/packages/flutter/lib/src/widgets/localizations.dart index 76403029a01..baf375a1358 100644 --- a/packages/flutter/lib/src/widgets/localizations.dart +++ b/packages/flutter/lib/src/widgets/localizations.dart @@ -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 diff --git a/packages/flutter/test/material/material_localizations_test.dart b/packages/flutter/test/material/material_localizations_test.dart index 445250280ea..b4eba2d1d84 100644 --- a/packages/flutter/test/material/material_localizations_test.dart +++ b/packages/flutter/test/material/material_localizations_test.dart @@ -32,6 +32,119 @@ void main() { final LocalizationTrackerState innerTracker = tester.state(find.byKey(const ValueKey('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 {