From 235b64ed2fb6150ecf08bde5f08dcd450c5a96b4 Mon Sep 17 00:00:00 2001 From: Yegor Date: Thu, 14 Dec 2017 20:38:39 -0800 Subject: [PATCH] make date picker accessible (#13502) * make date picker accessible * make test file lookup location-independent * address some comments * always wrap in IgnorePointer * no bitmasks for flags and actions * recommend List<*> --- .../flutter/lib/src/material/date_picker.dart | 159 +++++++--- .../src/material/material_localizations.dart | 30 +- .../test/material/date_picker_test.dart | 292 ++++++++++++++---- .../test/widgets/semantics_tester.dart | 146 ++++++++- ...xpressionForCurrentSemanticsTree_test.dart | 139 +++++++++ .../lib/src/material_localizations.dart | 11 + 6 files changed, 664 insertions(+), 113 deletions(-) create mode 100644 packages/flutter/test/widgets/semantics_tester_generateTestSemanticsExpressionForCurrentSemanticsTree_test.dart diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index acef552084..633b6d08e8 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -126,22 +126,31 @@ class _DatePickerHeader extends StatelessWidget { break; } - Widget yearButton = new _DateHeaderButton( - color: backgroundColor, - onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.year), context), - child: new Text(localizations.formatYear(selectedDate), style: yearStyle), - ); - Widget dayButton = new _DateHeaderButton( - color: backgroundColor, - onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.day), context), - child: new Text(localizations.formatMediumDate(selectedDate), style: dayStyle), + final Widget yearButton = new IgnorePointer( + ignoring: mode != DatePickerMode.day, + ignoringSemantics: false, + child: new _DateHeaderButton( + color: backgroundColor, + onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.year), context), + child: new Semantics( + selected: mode == DatePickerMode.year, + child: new Text(localizations.formatYear(selectedDate), style: yearStyle), + ), + ), ); - // Disable the button for the current mode. - if (mode == DatePickerMode.day) - dayButton = new IgnorePointer(child: dayButton); - else - yearButton = new IgnorePointer(child: yearButton); + final Widget dayButton = new IgnorePointer( + ignoring: mode == DatePickerMode.day, + ignoringSemantics: false, + child: new _DateHeaderButton( + color: backgroundColor, + onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.day), context), + child: new Semantics( + selected: mode == DatePickerMode.day, + child: new Text(localizations.formatMediumDate(selectedDate), style: dayStyle), + ), + ), + ); return new Container( width: width, @@ -238,7 +247,6 @@ class DayPicker extends StatelessWidget { @required this.firstDate, @required this.lastDate, @required this.displayedMonth, - this.onMonthHeaderTap, this.selectableDayPredicate, }) : assert(selectedDate != null), assert(currentDate != null), @@ -259,9 +267,6 @@ class DayPicker extends StatelessWidget { /// Called when the user picks a day. final ValueChanged onChanged; - /// Called when the user taps on the header that displays the current month. - final VoidCallback onMonthHeaderTap; - /// The earliest date the user is permitted to pick. final DateTime firstDate; @@ -296,7 +301,9 @@ class DayPicker extends StatelessWidget { final List result = []; 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))); + result.add(new ExcludeSemantics( + child: new Center(child: new Text(weekday, style: headerStyle)), + )); if (i == (localizations.firstDayOfWeekIndex - 1) % 7) break; } @@ -392,7 +399,8 @@ class DayPicker extends StatelessWidget { BoxDecoration decoration; TextStyle itemStyle = themeData.textTheme.body1; - if (selectedDate.year == year && selectedDate.month == month && selectedDate.day == day) { + final bool isSelectedDay = selectedDate.year == year && selectedDate.month == month && selectedDate.day == day; + if (isSelectedDay) { // The selected day gets a circle background highlight, and a contrasting text color. itemStyle = themeData.accentTextTheme.body2; decoration = new BoxDecoration( @@ -409,7 +417,19 @@ class DayPicker extends StatelessWidget { Widget dayWidget = new Container( decoration: decoration, child: new Center( - child: new Text(localizations.formatDecimal(day), style: itemStyle), + child: new Semantics( + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + label: '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}', + selected: isSelectedDay, + child: new ExcludeSemantics( + child: new Text(localizations.formatDecimal(day), style: itemStyle), + ), + ), ), ); @@ -434,9 +454,9 @@ class DayPicker extends StatelessWidget { new Container( height: _kDayPickerRowHeight, child: new Center( - child: new GestureDetector( - onTap: onMonthHeaderTap != null ? Feedback.wrapForTap(onMonthHeaderTap, context) : null, - child: new Text(localizations.formatMonthYear(displayedMonth), + child: new ExcludeSemantics( + child: new Text( + localizations.formatMonthYear(displayedMonth), style: themeData.textTheme.subhead, ), ), @@ -478,7 +498,6 @@ class MonthPicker extends StatefulWidget { @required this.firstDate, @required this.lastDate, this.selectableDayPredicate, - this.onMonthHeaderTap, }) : assert(selectedDate != null), assert(onChanged != null), assert(!firstDate.isAfter(lastDate)), @@ -493,9 +512,6 @@ class MonthPicker extends StatefulWidget { /// Called when the user picks a month. final ValueChanged onChanged; - /// Called when the user taps on the header that displays the current month. - final VoidCallback onMonthHeaderTap; - /// The earliest date the user is permitted to pick. final DateTime firstDate; @@ -514,8 +530,9 @@ class _MonthPickerState extends State { void initState() { super.initState(); // Initially display the pre-selected date. - _dayPickerController = new PageController(initialPage: _monthDelta(widget.firstDate, widget.selectedDate)); - _currentDisplayedMonthDate = new DateTime(widget.selectedDate.year, widget.selectedDate.month); + final int monthPage = _monthDelta(widget.firstDate, widget.selectedDate); + _dayPickerController = new PageController(initialPage: monthPage); + _handleMonthPageChanged(monthPage); _updateCurrentDate(); } @@ -523,12 +540,22 @@ class _MonthPickerState extends State { void didUpdateWidget(MonthPicker oldWidget) { super.didUpdateWidget(oldWidget); if (widget.selectedDate != oldWidget.selectedDate) { - _dayPickerController = new PageController(initialPage: _monthDelta(widget.firstDate, widget.selectedDate)); - _currentDisplayedMonthDate = - new DateTime(widget.selectedDate.year, widget.selectedDate.month); + final int monthPage = _monthDelta(widget.firstDate, widget.selectedDate); + _dayPickerController = new PageController(initialPage: monthPage); + _handleMonthPageChanged(monthPage); } } + MaterialLocalizations localizations; + TextDirection textDirection; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + localizations = MaterialLocalizations.of(context); + textDirection = Directionality.of(context); + } + DateTime _todayDate; DateTime _currentDisplayedMonthDate; Timer _timer; @@ -567,18 +594,21 @@ class _MonthPickerState extends State { lastDate: widget.lastDate, displayedMonth: month, selectableDayPredicate: widget.selectableDayPredicate, - onMonthHeaderTap: widget.onMonthHeaderTap, ); } void _handleNextMonth() { - if (!_isDisplayingLastMonth) + if (!_isDisplayingLastMonth) { + SemanticsService.announce(localizations.formatMonthYear(_nextMonthDate), textDirection); _dayPickerController.nextPage(duration: _kMonthScrollDuration, curve: Curves.ease); + } } void _handlePreviousMonth() { - if (!_isDisplayingFirstMonth) + if (!_isDisplayingFirstMonth) { + SemanticsService.announce(localizations.formatMonthYear(_previousMonthDate), textDirection); _dayPickerController.previousPage(duration: _kMonthScrollDuration, curve: Curves.ease); + } } /// True if the earliest allowable month is displayed. @@ -593,15 +623,19 @@ class _MonthPickerState extends State { new DateTime(widget.lastDate.year, widget.lastDate.month)); } + DateTime _previousMonthDate; + DateTime _nextMonthDate; + void _handleMonthPageChanged(int monthPage) { setState(() { + _previousMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage - 1); _currentDisplayedMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage); + _nextMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage + 1); }); } @override Widget build(BuildContext context) { - final MaterialLocalizations localizations = MaterialLocalizations.of(context); return new SizedBox( width: _kMonthPickerPortraitWidth, height: _kMaxDayPickerHeight, @@ -620,7 +654,7 @@ class _MonthPickerState extends State { start: 8.0, child: new IconButton( icon: const Icon(Icons.chevron_left), - tooltip: localizations.previousMonthTooltip, + tooltip: _isDisplayingFirstMonth ? null : '${localizations.previousMonthTooltip} ${localizations.formatMonthYear(_previousMonthDate)}', onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth, ), ), @@ -629,7 +663,7 @@ class _MonthPickerState extends State { end: 8.0, child: new IconButton( icon: const Icon(Icons.chevron_right), - tooltip: localizations.nextMonthTooltip, + tooltip: _isDisplayingLastMonth ? null : '${localizations.nextMonthTooltip} ${localizations.formatMonthYear(_nextMonthDate)}', onPressed: _isDisplayingLastMonth ? null : _handleNextMonth, ), ), @@ -640,8 +674,8 @@ class _MonthPickerState extends State { @override void dispose() { - if (_timer != null) - _timer.cancel(); + _timer?.cancel(); + _dayPickerController?.dispose(); super.dispose(); } } @@ -718,15 +752,20 @@ class _YearPickerState extends State { itemCount: widget.lastDate.year - widget.firstDate.year + 1, itemBuilder: (BuildContext context, int index) { final int year = widget.firstDate.year + index; - final TextStyle itemStyle = year == widget.selectedDate.year ? - themeData.textTheme.headline.copyWith(color: themeData.accentColor) : style; + final bool isSelected = year == widget.selectedDate.year; + final TextStyle itemStyle = isSelected + ? themeData.textTheme.headline.copyWith(color: themeData.accentColor) + : style; return new InkWell( key: new ValueKey(year), onTap: () { widget.onChanged(new DateTime(year, widget.selectedDate.month, widget.selectedDate.day)); }, child: new Center( - child: new Text(year.toString(), style: itemStyle), + child: new Semantics( + selected: isSelected, + child: new Text(year.toString(), style: itemStyle), + ), ), ); }, @@ -762,6 +801,25 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { _mode = widget.initialDatePickerMode; } + bool _announcedInitialDate = false; + + MaterialLocalizations localizations; + TextDirection textDirection; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + localizations = MaterialLocalizations.of(context); + textDirection = Directionality.of(context); + if (!_announcedInitialDate) { + _announcedInitialDate = true; + SemanticsService.announce( + localizations.formatFullDate(_selectedDate), + textDirection, + ); + } + } + DateTime _selectedDate; DatePickerMode _mode; final GlobalKey _pickerKey = new GlobalKey(); @@ -781,6 +839,11 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { _vibrate(); setState(() { _mode = mode; + if (_mode == DatePickerMode.day) { + SemanticsService.announce(localizations.formatMonthYear(_selectedDate), textDirection); + } else { + SemanticsService.announce(localizations.formatYear(_selectedDate), textDirection); + } }); } @@ -807,10 +870,6 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { Navigator.pop(context, _selectedDate); } - void _handleMonthHeaderTap() { - _handleModeChanged(DatePickerMode.year); - } - Widget _buildPicker() { assert(_mode != null); switch (_mode) { @@ -822,7 +881,6 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { firstDate: widget.firstDate, lastDate: widget.lastDate, selectableDayPredicate: widget.selectableDayPredicate, - onMonthHeaderTap: _handleMonthHeaderTap, ); case DatePickerMode.year: return new YearPicker( @@ -844,7 +902,6 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { child: _buildPicker(), ), ); - final MaterialLocalizations localizations = MaterialLocalizations.of(context); final Widget actions = new ButtonTheme.bar( child: new ButtonBar( children: [ @@ -862,13 +919,13 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { return new Dialog( child: new OrientationBuilder( builder: (BuildContext context, Orientation orientation) { + assert(orientation != null); final Widget header = new _DatePickerHeader( selectedDate: _selectedDate, mode: _mode, onModeChanged: _handleModeChanged, orientation: orientation, ); - assert(orientation != null); switch (orientation) { case Orientation.portrait: return new SizedBox( diff --git a/packages/flutter/lib/src/material/material_localizations.dart b/packages/flutter/lib/src/material/material_localizations.dart index 924d4afb55..7b809f4895 100644 --- a/packages/flutter/lib/src/material/material_localizations.dart +++ b/packages/flutter/lib/src/material/material_localizations.dart @@ -196,6 +196,17 @@ abstract class MaterialLocalizations { /// - Russian: ср, сент. 27 String formatMediumDate(DateTime date); + /// Formats day of week, month, day of month and year in a long-width format. + /// + /// Does not abbreviate names. Appears in spoken announcements of the date + /// picker invoked using [showDatePicker], when accessibility mode is on. + /// + /// Examples: + /// + /// - US English: Wednesday, September 27, 2017 + /// - Russian: Среда, Сентябрь 27, 2017 + String formatFullDate(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 @@ -275,7 +286,7 @@ class DefaultMaterialLocalizations implements MaterialLocalizations { const DefaultMaterialLocalizations(); // Ordered to match DateTime.MONDAY=1, DateTime.SUNDAY=6 - static const List_shortWeekdays = const [ + static const List _shortWeekdays = const [ 'Mon', 'Tue', 'Wed', @@ -285,6 +296,17 @@ class DefaultMaterialLocalizations implements MaterialLocalizations { 'Sun', ]; + // Ordered to match DateTime.MONDAY=1, DateTime.SUNDAY=6 + static const List _weekdays = const [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + static const List _narrowWeekdays = const [ 'S', 'M', @@ -365,6 +387,12 @@ class DefaultMaterialLocalizations implements MaterialLocalizations { return '$day, $month ${date.day}'; } + @override + String formatFullDate(DateTime date) { + final String month = _months[date.month - DateTime.JANUARY]; + return '${_weekdays[date.weekday - DateTime.MONDAY]}, $month ${date.day}, ${date.year}'; + } + @override String formatMonthYear(DateTime date) { final String year = formatYear(date); diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index 69a7016b9b..c3a01d1b1b 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -2,17 +2,28 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; void main() { + group('showDatePicker', () { + _tests(); + }); +} + +void _tests() { DateTime firstDate; DateTime lastDate; DateTime initialDate; SelectableDayPredicate selectableDayPredicate; DatePickerMode initialDatePickerMode; + final Finder nextMonthIcon = find.byWidgetPredicate((Widget w) => w is IconButton && (w.tooltip?.startsWith('Next month') ?? false)); + final Finder previousMonthIcon = find.byWidgetPredicate((Widget w) => w is IconButton && (w.tooltip?.startsWith('Previous month') ?? false)); setUp(() { firstDate = new DateTime(2001, DateTime.JANUARY, 1); @@ -63,7 +74,7 @@ void main() { await tester.pumpAndSettle(); expect(_selectedDate, equals(new DateTime(2016, DateTime.JULY, 1))); - await tester.tap(find.byTooltip('Next month')); + await tester.tap(nextMonthIcon); await tester.pumpAndSettle(); expect(_selectedDate, equals(new DateTime(2016, DateTime.JULY, 1))); @@ -114,38 +125,6 @@ void main() { await tester.pump(const Duration(seconds: 5)); }); - testWidgets('MonthPicker receives header taps', (WidgetTester tester) async { - DateTime currentValue; - bool headerTapped = false; - - final Widget widget = new MaterialApp( - home: new Material( - child: new ListView( - children: [ - 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; - }, - onMonthHeaderTap: () { - headerTapped = true; - }, - ), - ], - ), - ), - ); - - await tester.pumpWidget(widget); - - expect(currentValue, isNull); - expect(headerTapped, false); - await tester.tap(find.text('June 2015')); - expect(headerTapped, true); - }); - Future preparePicker(WidgetTester tester, Future callback(Future date)) async { BuildContext buttonContext; await tester.pumpWidget(new MaterialApp( @@ -214,7 +193,7 @@ void main() { testWidgets('Can select a month', (WidgetTester tester) async { await preparePicker(tester, (Future date) async { - await tester.tap(find.byTooltip('Previous month')); + await tester.tap(previousMonthIcon); await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.tap(find.text('25')); await tester.tap(find.text('OK')); @@ -279,17 +258,10 @@ void main() { firstDate = initialDate; lastDate = new DateTime(2017, DateTime.FEBRUARY, 20); await preparePicker(tester, (Future date) async { - await tester.tap(find.byTooltip('Next month')); + await tester.tap(nextMonthIcon); await tester.pumpAndSettle(const Duration(seconds: 1)); // Shouldn't be possible to keep going into March. - await tester.tap(find.byTooltip('Next month')); - await tester.pumpAndSettle(const Duration(seconds: 1)); - // We're still in February - await tester.tap(find.text('20')); - // Days outside bound for new month pages also disabled. - await tester.tap(find.text('25')); - await tester.tap(find.text('OK')); - expect(await date, equals(new DateTime(2017, DateTime.FEBRUARY, 20))); + expect(nextMonthIcon, findsNothing); }); }); @@ -298,17 +270,10 @@ void main() { firstDate = new DateTime(2016, DateTime.DECEMBER, 10); lastDate = initialDate; await preparePicker(tester, (Future date) async { - await tester.tap(find.byTooltip('Previous month')); + await tester.tap(previousMonthIcon); await tester.pumpAndSettle(const Duration(seconds: 1)); // Shouldn't be possible to keep going into November. - await tester.tap(find.byTooltip('Previous month')); - await tester.pumpAndSettle(const Duration(seconds: 1)); - // We're still in December - await tester.tap(find.text('10')); - // Days outside bound for new month pages also disabled. - await tester.tap(find.text('5')); - await tester.tap(find.text('OK')); - expect(await date, equals(new DateTime(2016, DateTime.DECEMBER, 10))); + expect(previousMonthIcon, findsNothing); }); }); @@ -417,4 +382,227 @@ void main() { expect(await date, isNull); }); }); + + testWidgets('exports semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + await preparePicker(tester, (Future date) async { + final TestSemantics expected = new TestSemantics( + children: [ + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + flags: [SemanticsFlags.isSelected], + actions: [SemanticsAction.tap], + label: r'Fri, Jan 15', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + children: [ + new TestSemantics( + actions: [SemanticsAction.scrollLeft, SemanticsAction.scrollRight], + children: [ + new TestSemantics( + children: [ + new TestSemantics( + children: [ + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'1, Friday, January 1, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'2, Saturday, January 2, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'3, Sunday, January 3, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'4, Monday, January 4, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'5, Tuesday, January 5, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'6, Wednesday, January 6, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'7, Thursday, January 7, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'8, Friday, January 8, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'9, Saturday, January 9, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'10, Sunday, January 10, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'11, Monday, January 11, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'12, Tuesday, January 12, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'13, Wednesday, January 13, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'14, Thursday, January 14, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + flags: [SemanticsFlags.isSelected], + actions: [SemanticsAction.tap], + label: r'15, Friday, January 15, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'16, Saturday, January 16, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'17, Sunday, January 17, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'18, Monday, January 18, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'19, Tuesday, January 19, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'20, Wednesday, January 20, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'21, Thursday, January 21, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'22, Friday, January 22, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'23, Saturday, January 23, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'24, Sunday, January 24, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'25, Monday, January 25, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'26, Tuesday, January 26, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'27, Wednesday, January 27, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'28, Thursday, January 28, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'29, Friday, January 29, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'30, Saturday, January 30, 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'31, Sunday, January 31, 2016', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'Previous month December 2015', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + actions: [SemanticsAction.tap], + label: r'Next month February 2016', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + flags: [SemanticsFlags.isButton], + actions: [SemanticsAction.tap], + label: r'CANCEL', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + flags: [SemanticsFlags.isButton], + actions: [SemanticsAction.tap], + label: r'OK', + textDirection: TextDirection.ltr, + ), + ], + ); + + expect(semantics, hasSemantics( + expected, + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + )); + }); + }); } diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 53ffe05c84..26b2c9e451 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -46,7 +46,8 @@ class TestSemantics { this.transform, this.children: const [], Iterable tags, - }) : assert(flags != null), + }) : assert(flags is int || flags is List), + assert(actions is int || actions is List), assert(label != null), assert(value != null), assert(increasedValue != null), @@ -70,7 +71,8 @@ class TestSemantics { this.children: const [], Iterable tags, }) : id = 0, - assert(flags != null), + assert(flags is int || flags is List), + assert(actions is int || actions is List), assert(label != null), assert(increasedValue != null), assert(decreasedValue != null), @@ -103,7 +105,8 @@ class TestSemantics { Matrix4 transform, this.children: const [], Iterable tags, - }) : assert(flags != null), + }) : assert(flags is int || flags is List), + assert(actions is int || actions is List), assert(label != null), assert(value != null), assert(increasedValue != null), @@ -119,11 +122,24 @@ class TestSemantics { /// they are created. final int id; - /// A bit field of [SemanticsFlags] that apply to this node. - final int flags; + /// The [SemanticsFlags] set on this node. + /// + /// There are two ways to specify this property: as an `int` that encodes the + /// flags as a bit field, or as a `List` that are _on_. + /// + /// Using `List` is recommended due to better readability. + final dynamic flags; - /// A bit field of [SemanticsActions] that apply to this node. - final int actions; + /// The [SemanticsAction]s set on this node. + /// + /// There are two ways to specify this property: as an `int` that encodes the + /// actions as a bit field, or as a `List`. + /// + /// Using `List` is recommended due to better readability. + /// + /// The tester does not check the function corresponding to the action, but + /// only its existence. + final dynamic actions; /// A textual description of this node. final String label; @@ -204,10 +220,19 @@ class TestSemantics { return fail('could not find node with id $id.'); if (!ignoreId && id != node.id) return fail('expected node id $id but found id ${node.id}.'); - if (flags != nodeData.flags) + + final int flagsBitmask = flags is int + ? flags + : flags.fold(0, (int bitmask, SemanticsFlags flag) => bitmask | flag.index); + if (flagsBitmask != nodeData.flags) return fail('expected node id $id to have flags $flags but found flags ${nodeData.flags}.'); - if (actions != nodeData.actions) + + final int actionsBitmask = actions is int + ? actions + : actions.fold(0, (int bitmask, SemanticsAction action) => bitmask | action.index); + if (actionsBitmask != nodeData.actions) return fail('expected node id $id to have actions $actions but found actions ${nodeData.actions}.'); + if (label != nodeData.label) return fail('expected node id $id to have label "$label" but found label "${nodeData.label}".'); if (value != nodeData.value) @@ -340,6 +365,109 @@ class SemanticsTester { visit(tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode); return result; } + + /// Generates an expression that creates a [TestSemantics] reflecting the + /// current tree of [SemanticsNode]s. + /// + /// Use this method to generate code for unit tests. It works similar to + /// screenshot testing. The very first time you add semantics to a widget you + /// verify manually that the widget behaves correctly. You then use ths method + /// to generate test code for this widget. + /// + /// Example: + /// + /// ```dart + /// testWidgets('generate code for MyWidget', (WidgetTester tester) async { + /// var semantics = new SemanticsTester(tester); + /// await tester.pumpWidget(new MyWidget()); + /// print(semantics.generateTestSemanticsExpressionForCurrentSemanticsTree()); + /// semantics.dispose(); + /// }); + /// ``` + /// + /// You can now copy the code printed to the console into a unit test: + /// + /// ```dart + /// testWidgets('generate code for MyWidget', (WidgetTester tester) async { + /// var semantics = new SemanticsTester(tester); + /// await tester.pumpWidget(new MyWidget()); + /// expect(semantics, hasSemantics( + /// // Generated code: + /// new TestSemantics( + /// ... properties and child nodes ... + /// ), + /// ignoreRect: true, + /// ignoreTransform: true, + /// ignoreId: true, + /// )); + /// semantics.dispose(); + /// }); + /// + /// At this point the unit test should automatically pass because it was + /// generated from the actual [SemanticsNode]s. Next time the semantics tree + /// changes, the test code may either be updated manually, or regenerated and + /// replaced using this method again. + /// + /// Avoid submitting huge piles of generated test code. This will make test + /// code hard to review and it will make it tempting to regenerate test code + /// every time and ignore potential regressions. Make sure you do not + /// over-test. Prefer breaking your widgets into smaller widgets and test them + /// individually. + String generateTestSemanticsExpressionForCurrentSemanticsTree() { + final SemanticsNode node = tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode; + return _generateSemanticsTestForNode(node, 0); + } + + String _flagsToSemanticsFlagsExpression(int bitmap) { + return SemanticsFlags.values.values + .where((SemanticsFlags flag) => (flag.index & bitmap) != 0) + .join(', '); + } + + String _actionsToSemanticsActionExpression(int bitmap) { + return SemanticsAction.values.values + .where((SemanticsAction action) => (action.index & bitmap) != 0) + .join(', '); + } + + /// Recursively generates [TestSemantics] code for [node] and its children, + /// indenting the expression by `indentAmount`. + String _generateSemanticsTestForNode(SemanticsNode node, int indentAmount) { + final String indent = ' ' * indentAmount; + final StringBuffer buf = new StringBuffer(); + final SemanticsData nodeData = node.getSemanticsData(); + buf.writeln('new TestSemantics('); + if (nodeData.flags != 0) + buf.writeln(' flags: [${_flagsToSemanticsFlagsExpression(nodeData.flags)}],'); + if (nodeData.actions != 0) + buf.writeln(' actions: [${_actionsToSemanticsActionExpression(nodeData.actions)}],'); + if (node.label != null && node.label.isNotEmpty) + buf.writeln(' label: r\'${node.label}\','); + if (node.value != null && node.value.isNotEmpty) + buf.writeln(' value: r\'${node.value}\','); + if (node.increasedValue != null && node.increasedValue.isNotEmpty) + buf.writeln(' increasedValue: r\'${node.increasedValue}\','); + if (node.decreasedValue != null && node.decreasedValue.isNotEmpty) + buf.writeln(' decreasedValue: r\'${node.decreasedValue}\','); + if (node.hint != null && node.hint.isNotEmpty) + buf.writeln(' hint: r\'${node.hint}\','); + if (node.textDirection != null) + buf.writeln(' textDirection: ${node.textDirection},'); + + if (node.hasChildren) { + buf.writeln(' children: ['); + node.visitChildren((SemanticsNode child) { + buf + ..write(_generateSemanticsTestForNode(child, 2)) + ..writeln(','); + return true; + }); + buf.writeln(' ],'); + } + + buf.write(')'); + return buf.toString().split('\n').map((String l) => '$indent$l').join('\n'); + } } class _HasSemantics extends Matcher { diff --git a/packages/flutter/test/widgets/semantics_tester_generateTestSemanticsExpressionForCurrentSemanticsTree_test.dart b/packages/flutter/test/widgets/semantics_tester_generateTestSemanticsExpressionForCurrentSemanticsTree_test.dart new file mode 100644 index 0000000000..bf3c76fe3f --- /dev/null +++ b/packages/flutter/test/widgets/semantics_tester_generateTestSemanticsExpressionForCurrentSemanticsTree_test.dart @@ -0,0 +1,139 @@ +// 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:async'; +import 'dart:io'; +import 'dart:ui' show SemanticsFlags; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'semantics_tester.dart'; + +void main() { + group('generateTestSemanticsExpressionForCurrentSemanticsTree', () { + _tests(); + }); +} + +void _tests() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + Future pumpTestWidget(WidgetTester tester) async { + await tester.pumpWidget(new MaterialApp( + home: new ListView( + children: [ + const Text('Plain text'), + new Semantics( + selected: true, + checked: true, + onTap: () {}, + onDecrease: () {}, + value: 'test-value', + increasedValue: 'test-increasedValue', + decreasedValue: 'test-decreasedValue', + hint: 'test-hint', + textDirection: TextDirection.rtl, + child: const Text('Interactive text'), + ), + ], + ), + )); + } + + // This test generates code using generateTestSemanticsExpressionForCurrentSemanticsTree + // then compares it to the code used in the 'generated code is correct' test + // below. When you update the implementation of generateTestSemanticsExpressionForCurrentSemanticsTree + // also update this code to reflect the new output. + // + // This test is flexible w.r.t. leading and trailing whitespace. + testWidgets('generates code', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + await pumpTestWidget(tester); + final String code = semantics + .generateTestSemanticsExpressionForCurrentSemanticsTree() + .split('\n') + .map((String line) => line.trim()) + .join('\n') + .trim() + ','; + + File findThisTestFile(Directory directory) { + for (FileSystemEntity entity in directory.listSync()) { + if (entity is Directory) { + final File childSearch = findThisTestFile(entity); + if (childSearch != null) { + return childSearch; + } + } else if (entity is File && entity.path.endsWith('semantics_tester_generateTestSemanticsExpressionForCurrentSemanticsTree_test.dart')) { + return entity; + } + } + return null; + } + + final File thisTestFile = findThisTestFile(Directory.current); + expect(thisTestFile, isNotNull); + String expectedCode = thisTestFile.readAsStringSync(); + expectedCode = expectedCode.substring( + expectedCode.indexOf('>' * 12) + 12, + expectedCode.indexOf('<' * 12) - 3, + ) + .split('\n') + .map((String line) => line.trim()) + .join('\n') + .trim(); + semantics.dispose(); + expect(code, expectedCode); + }); + + testWidgets('generated code is correct', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + await pumpTestWidget(tester); + expect( + semantics, + hasSemantics( + // The code below delimited by > and < characters is generated by + // generateTestSemanticsExpressionForCurrentSemanticsTree function. + // You must update it when changing the output generated by + // generateTestSemanticsExpressionForCurrentSemanticsTree. Otherwise, + // the test 'generates code', defined above, will fail. + // >>>>>>>>>>>> + new TestSemantics( + children: [ + new TestSemantics( + children: [ + new TestSemantics( + children: [ + new TestSemantics( + label: r'Plain text', + textDirection: TextDirection.ltr, + ), + new TestSemantics( + flags: [SemanticsFlags.hasCheckedState, SemanticsFlags.isChecked, SemanticsFlags.isSelected], + actions: [SemanticsAction.tap, SemanticsAction.decrease], + label: r'‪Interactive text‬', + value: r'test-value', + increasedValue: r'test-increasedValue', + decreasedValue: r'test-decreasedValue', + hint: r'test-hint', + textDirection: TextDirection.rtl, + ), + ], + ), + ], + ), + ], + ), + // <<<<<<<<<<<< + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ) + ); + semantics.dispose(); + }); +} diff --git a/packages/flutter_localizations/lib/src/material_localizations.dart b/packages/flutter_localizations/lib/src/material_localizations.dart index 250a06aad3..9348a6a832 100644 --- a/packages/flutter_localizations/lib/src/material_localizations.dart +++ b/packages/flutter_localizations/lib/src/material_localizations.dart @@ -76,14 +76,18 @@ class GlobalMaterialLocalizations implements MaterialLocalizations { if (intl.DateFormat.localeExists(_localeName)) { _fullYearFormat = new intl.DateFormat.y(_localeName); _mediumDateFormat = new intl.DateFormat(kMediumDatePattern, _localeName); + _longDateFormat = new intl.DateFormat.yMMMMEEEEd(_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); + + _longDateFormat = new intl.DateFormat.yMMMMEEEEd(locale.languageCode); _yearMonthFormat = new intl.DateFormat('yMMMM', locale.languageCode); } else { _fullYearFormat = new intl.DateFormat.y(); _mediumDateFormat = new intl.DateFormat(kMediumDatePattern); + _longDateFormat = new intl.DateFormat.yMMMMEEEEd(); _yearMonthFormat = new intl.DateFormat('yMMMM'); } @@ -115,6 +119,8 @@ class GlobalMaterialLocalizations implements MaterialLocalizations { intl.DateFormat _mediumDateFormat; + intl.DateFormat _longDateFormat; + intl.DateFormat _yearMonthFormat; static String _computeLocaleName(Locale locale) { @@ -169,6 +175,11 @@ class GlobalMaterialLocalizations implements MaterialLocalizations { return _mediumDateFormat.format(date); } + @override + String formatFullDate(DateTime date) { + return _longDateFormat.format(date); + } + @override String formatMonthYear(DateTime date) { return _yearMonthFormat.format(date);