Commit 150c5830 authored by Yegor's avatar Yegor Committed by GitHub

Date picker i18n (#12324)

* formatYear

* localize date picker

* tests

* clean-ups

* address comments
parent b6185b66
......@@ -9,8 +9,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/date_symbols.dart' as intl show DateSymbols;
import 'package:intl/intl.dart' as intl show DateFormat;
import 'button.dart';
import 'button_bar.dart';
......@@ -83,6 +81,7 @@ class _DatePickerHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final ThemeData themeData = Theme.of(context);
final TextTheme headerTextTheme = themeData.primaryTextTheme;
Color dayColor;
......@@ -130,12 +129,12 @@ class _DatePickerHeader extends StatelessWidget {
Widget yearButton = new _DateHeaderButton(
color: backgroundColor,
onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.year), context),
child: new Text(new intl.DateFormat('yyyy').format(selectedDate), style: yearStyle),
child: new Text(localizations.formatYear(selectedDate), style: yearStyle),
);
Widget dayButton = new _DateHeaderButton(
color: backgroundColor,
onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.day), context),
child: new Text(new intl.DateFormat('E, MMM\u00a0d').format(selectedDate), style: dayStyle),
child: new Text(localizations.formatMediumDate(selectedDate), style: dayStyle),
);
// Disable the button for the current mode.
......@@ -275,12 +274,33 @@ class DayPicker extends StatelessWidget {
/// Optional user supplied predicate function to customize selectable days.
final SelectableDayPredicate selectableDayPredicate;
List<Widget> _getDayHeaders(TextStyle headerStyle) {
final intl.DateFormat dateFormat = new intl.DateFormat();
final intl.DateSymbols symbols = dateFormat.dateSymbols;
return symbols.NARROWWEEKDAYS.map((String weekDay) {
return new Center(child: new Text(weekDay, style: headerStyle));
}).toList(growable: false);
/// Builds widgets showing abbreviated days of week. The first widget in the
/// returned list corresponds to the first day of week for the current locale.
///
/// Examples:
///
/// ```
/// ┌ Sunday is the first day of week in the US (en_US)
/// |
/// S M T W T F S <-- the returned list contains these widgets
/// _ _ _ _ _ 1 2
/// 3 4 5 6 7 8 9
///
/// ┌ But it's Monday in the UK (en_GB)
/// |
/// M T W T F S S <-- the returned list contains these widgets
/// _ _ _ _ 1 2 3
/// 4 5 6 7 8 9 10
/// ```
List<Widget> _getDayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) {
final List<Widget> result = <Widget>[];
for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) {
final String weekDay = localizations.narrowWeekDays[i];
result.add(new Center(child: new Text(weekDay, style: headerStyle)));
if (i == (localizations.firstDayOfWeekIndex - 1) % 7)
break;
}
return result;
}
// Do not use this directly - call getDaysInMonth instead.
......@@ -301,18 +321,64 @@ class DayPicker extends StatelessWidget {
return _kDaysInMonth[month - 1];
}
/// Computes the offset from the first day of week that the first day of the
/// [month] falls on.
///
/// For example, September 1, 2017 falls on a Friday, which in the calendar
/// localized for United States English appears as:
///
/// ```
/// S M T W T F S
/// _ _ _ _ _ 1 2
/// ```
///
/// The offset for the first day of the months is the number of leading blanks
/// in the calendar, i.e. 5.
///
/// The same date localized for the Russian calendar has a different offset,
/// because the first day of week is Monday rather than Sunday:
///
/// ```
/// M T W T F S S
/// _ _ _ _ 1 2 3
/// ```
///
/// So the offset is 4, rather than 5.
///
/// This code consolidates the following:
///
/// - [DateTime.weekday] provides a 1-based index into days of week, with 1
/// falling on Monday.
/// - [MaterialLocalizations.firstDayOfWeekIndex] provides a 0-based index
/// into the [MaterialLocalizations.narrowWeekDays] list.
/// - [MaterialLocalizations.narrowWeekDays] list provides localized names of
/// days of week, always starting with Sunday and ending with Saturday.
int _computeFirstDayOffset(int year, int month, MaterialLocalizations localizations) {
// 0-based day of week, with 0 representing Monday.
final int weekDayFromMonday = new DateTime(year, month).weekday - 1;
// 0-based day of week, with 0 representing Sunday.
final int firstDayOfWeekFromSunday = localizations.firstDayOfWeekIndex;
// firstDayOfWeekFromSunday recomputed to be Monday-based
final int firstDayOfWeekFromMonday = (firstDayOfWeekFromSunday - 1) % 7;
// Number of days between the first day of week appearing on the calendar,
// and the day corresponding to the 1-st of the month.
return (weekDayFromMonday - firstDayOfWeekFromMonday) % 7;
}
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final int year = displayedMonth.year;
final int month = displayedMonth.month;
final int daysInMonth = getDaysInMonth(year, month);
// This assumes a start day of SUNDAY, but could be changed.
final int firstWeekday = new DateTime(year, month).weekday % 7;
final int firstDayOffset = _computeFirstDayOffset(year, month, localizations);
final List<Widget> labels = <Widget>[];
labels.addAll(_getDayHeaders(themeData.textTheme.caption));
for (int i = 0; true; ++i) {
final int day = i - firstWeekday + 1;
labels.addAll(_getDayHeaders(themeData.textTheme.caption, localizations));
for (int i = 0; true; i += 1) {
// 1-based day of month, e.g. 1-31 for January, and 1-29 for February on
// a leap year.
final int day = i - firstDayOffset + 1;
if (day > daysInMonth)
break;
if (day < 1) {
......@@ -370,7 +436,7 @@ class DayPicker extends StatelessWidget {
child: new Center(
child: new GestureDetector(
onTap: onMonthHeaderTap != null ? Feedback.wrapForTap(onMonthHeaderTap, context) : null,
child: new Text(new intl.DateFormat('yMMMM').format(displayedMonth),
child: new Text(localizations.formatMonthYear(displayedMonth),
style: themeData.textTheme.subhead,
),
),
......@@ -558,6 +624,7 @@ class _MonthPickerState extends State<MonthPicker> {
@override
Widget build(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
return new SizedBox(
width: _kMonthPickerPortraitWidth,
height: _kMaxDayPickerHeight,
......@@ -576,7 +643,7 @@ class _MonthPickerState extends State<MonthPicker> {
start: 8.0,
child: new IconButton(
icon: _getPreviousMonthIcon(textDirection),
tooltip: MaterialLocalizations.of(context).previousMonthTooltip,
tooltip: localizations.previousMonthTooltip,
onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth,
),
),
......@@ -585,7 +652,7 @@ class _MonthPickerState extends State<MonthPicker> {
end: 8.0,
child: new IconButton(
icon: _getNextMonthIcon(textDirection),
tooltip: MaterialLocalizations.of(context).nextMonthTooltip,
tooltip: localizations.nextMonthTooltip,
onPressed: _isDisplayingLastMonth ? null : _handleNextMonth,
),
),
......@@ -882,6 +949,14 @@ typedef bool SelectableDayPredicate(DateTime day);
/// date picker initially in the year or month+day picker mode. It defaults
/// to month+day, and must not be null.
///
/// An optional [locale] argument can be used to set the locale for the date
/// picker. It defaults to the ambient locale provided by [Localizations].
///
/// An optional [textDirection] argument can be used to set the text direction
/// (RTL or LTR) for the date picker. It defaults to the ambient text direction
/// provided by [Directionality]. If both [locale] and [textDirection] are not
/// null, [textDirection] overrides the direction chosen for the [locale].
///
/// See also:
///
/// * [showTimePicker]
......@@ -893,6 +968,8 @@ Future<DateTime> showDatePicker({
@required DateTime lastDate,
SelectableDayPredicate selectableDayPredicate,
DatePickerMode initialDatePickerMode: DatePickerMode.day,
Locale locale,
TextDirection textDirection,
}) async {
assert(!initialDate.isBefore(firstDate), 'initialDate must be on or after firstDate');
assert(!initialDate.isAfter(lastDate), 'initialDate must be on or before lastDate');
......@@ -902,14 +979,32 @@ Future<DateTime> showDatePicker({
'Provided initialDate must satisfy provided selectableDayPredicate'
);
assert(initialDatePickerMode != null, 'initialDatePickerMode must not be null');
return await showDialog(
context: context,
child: new _DatePickerDialog(
Widget child = new _DatePickerDialog(
initialDate: initialDate,
firstDate: firstDate,
lastDate: lastDate,
selectableDayPredicate: selectableDayPredicate,
initialDatePickerMode: initialDatePickerMode,
)
);
if (textDirection != null) {
child = new Directionality(
textDirection: textDirection,
child: child,
);
}
if (locale != null) {
child = new Localizations.override(
context: context,
locale: locale,
child: child,
);
}
return await showDialog<DateTime>(
context: context,
child: child,
);
}
......@@ -7,6 +7,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart' as intl;
import 'package:intl/date_symbols.dart' as intl;
import 'package:intl/date_symbol_data_local.dart' as intl_local_date_data;
import 'i18n/localizations.dart';
import 'time.dart';
......@@ -121,6 +123,52 @@ abstract class MaterialLocalizations {
/// Formats [timeOfDay] according to the value of [timeOfDayFormat].
String formatTimeOfDay(TimeOfDay timeOfDay);
/// Full unabbreviated year format, e.g. 2017 rather than 17.
String formatYear(DateTime date);
/// Formats the date using a medium-width format.
///
/// Abbreviates month and days of week. This appears in the header of the date
/// picker invoked using [showDatePicker].
///
/// Examples:
///
/// - US English: Wed, Sep 27
/// - Russian: ср, сент. 27
String formatMediumDate(DateTime date);
/// Formats the month and the year of the given [date].
///
/// The returned string does not contain the day of the month. This appears
/// in the date picker invoked using [showDatePicker].
String formatMonthYear(DateTime date);
/// List of week day names in narrow format, usually 1- or 2-letter
/// abbreviations of full names.
///
/// The list begins with the value corresponding to Sunday and ends with
/// Saturday. Use [firstDayOfWeekIndex] to find the first day of week in this
/// list.
///
/// Examples:
///
/// - US English: S, M, T, W, T, F, S
/// - Russian: вс, пн, вт, ср, чт, пт, сб - notice that the list begins with
/// вс (Sunday) even though the first day of week for Russian is Monday.
List<String> get narrowWeekDays;
/// Index of the first day of week, where 0 points to Sunday, and 6 points to
/// Saturday.
///
/// This getter is compatible with [narrowWeekDays]. For example:
///
/// ```dart
/// var localizations = MaterialLocalizations.of(context);
/// // The name of the first day of week for the current locale.
/// var firstDayOfWeek = localizations.narrowWeekDays[localizations.firstDayOfWeekIndex];
/// ```
int get firstDayOfWeekIndex;
/// The `MaterialLocalizations` from the closest [Localizations] instance
/// that encloses the given context.
///
......@@ -146,13 +194,30 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
/// [LocalizationsDelegate] implementations typically call the static [load]
/// function, rather than constructing this class directly.
DefaultMaterialLocalizations(this.locale)
: this._localeName = _computeLocaleName(locale) {
assert(locale != null);
: assert(locale != null),
this._localeName = _computeLocaleName(locale) {
_loadDateIntlDataIfNotLoaded();
if (localizations.containsKey(locale.languageCode))
_nameToValue.addAll(localizations[locale.languageCode]);
if (localizations.containsKey(_localeName))
_nameToValue.addAll(localizations[_localeName]);
const String kMediumDatePattern = 'E, MMM\u00a0d';
if (intl.DateFormat.localeExists(_localeName)) {
_fullYearFormat = new intl.DateFormat.y(_localeName);
_mediumDateFormat = new intl.DateFormat(kMediumDatePattern, _localeName);
_yearMonthFormat = new intl.DateFormat('yMMMM', _localeName);
} else if (intl.DateFormat.localeExists(locale.languageCode)) {
_fullYearFormat = new intl.DateFormat.y(locale.languageCode);
_mediumDateFormat = new intl.DateFormat(kMediumDatePattern, locale.languageCode);
_yearMonthFormat = new intl.DateFormat('yMMMM', locale.languageCode);
} else {
_fullYearFormat = new intl.DateFormat.y();
_mediumDateFormat = new intl.DateFormat(kMediumDatePattern);
_yearMonthFormat = new intl.DateFormat('yMMMM');
}
if (intl.NumberFormat.localeExists(_localeName)) {
_decimalFormat = new intl.NumberFormat.decimalPattern(_localeName);
_twoDigitZeroPaddedFormat = new intl.NumberFormat('00', _localeName);
......@@ -183,6 +248,13 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
/// If the number is less than 10, zero-pads it.
intl.NumberFormat _twoDigitZeroPaddedFormat;
/// Full unabbreviated year format, e.g. 2017 rather than 17.
intl.DateFormat _fullYearFormat;
intl.DateFormat _mediumDateFormat;
intl.DateFormat _yearMonthFormat;
static String _computeLocaleName(Locale locale) {
final String localeName = locale.countryCode.isEmpty ? locale.languageCode : locale.toString();
return intl.Intl.canonicalizedLocale(localeName);
......@@ -225,6 +297,29 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
return _twoDigitZeroPaddedFormat.format(timeOfDay.minute);
}
@override
String formatYear(DateTime date) {
return _fullYearFormat.format(date);
}
@override
String formatMediumDate(DateTime date) {
return _mediumDateFormat.format(date);
}
@override
String formatMonthYear(DateTime date) {
return _yearMonthFormat.format(date);
}
@override
List<String> get narrowWeekDays {
return _fullYearFormat.dateSymbols.NARROWWEEKDAYS;
}
@override
int get firstDayOfWeekIndex => (_fullYearFormat.dateSymbols.FIRSTDAYOFWEEK + 1) % 7;
/// Formats a [number] using local decimal number format.
///
/// Inserts locale-appropriate thousands separator, if necessary.
......@@ -415,3 +510,20 @@ 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,
};
/// Tracks if date i18n data has been loaded.
bool _dateIntlDataInitialized = false;
/// Loads i18n data for dates if it hasn't be loaded yet.
///
/// Only the first invocation of this function has the effect of loading the
/// data. Subsequent invocations have no effect.
void _loadDateIntlDataIfNotLoaded() {
if (!_dateIntlDataInitialized) {
// The returned Future is intentionally dropped on the floor. The
// function only returns it to be compatible with the async counterparts.
// The Future has no value otherwise.
intl_local_date_data.initializeDateFormatting();
_dateIntlDataInitialized = true;
}
}
// Copyright 2015 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 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
void main() {
testWidgets('Can select a day', (WidgetTester tester) async {
DateTime currentValue;
final Widget widget = new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new ListView(
children: <Widget>[
new MonthPicker(
selectedDate: new DateTime.utc(2015, 6, 9, 7, 12),
firstDate: new DateTime.utc(2013),
lastDate: new DateTime.utc(2018),
onChanged: (DateTime dateTime) {
currentValue = dateTime;
},
),
],
),
),
);
await tester.pumpWidget(widget);
expect(currentValue, isNull);
await tester.tap(find.text('2015'));
await tester.pumpWidget(widget);
await tester.tap(find.text('2014'));
await tester.pumpWidget(widget);
expect(currentValue, equals(new DateTime(2014, 6, 9)));
await tester.tap(find.text('30'));
expect(currentValue, equals(new DateTime(2013, 1, 30)));
}, skip: true);
}
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