Unverified Commit 235b64ed authored by Yegor's avatar Yegor Committed by GitHub

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<*>
parent dc9c9537
......@@ -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<DateTime> 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<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)));
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<DateTime> 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<MonthPicker> {
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<MonthPicker> {
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<MonthPicker> {
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<MonthPicker> {
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<MonthPicker> {
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<MonthPicker> {
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<MonthPicker> {
@override
void dispose() {
if (_timer != null)
_timer.cancel();
_timer?.cancel();
_dayPickerController?.dispose();
super.dispose();
}
}
......@@ -718,15 +752,20 @@ class _YearPickerState extends State<YearPicker> {
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<int>(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: <Widget>[
......@@ -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(
......
......@@ -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<String>_shortWeekdays = const <String>[
static const List<String> _shortWeekdays = const <String>[
'Mon',
'Tue',
'Wed',
......@@ -285,6 +296,17 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
'Sun',
];
// Ordered to match DateTime.MONDAY=1, DateTime.SUNDAY=6
static const List<String> _weekdays = const <String>[
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
static const List<String> _narrowWeekdays = const <String>[
'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);
......
......@@ -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: <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;
},
onMonthHeaderTap: () {
headerTapped = true;
},
),
],
),
),
);
await tester.pumpWidget(widget);
expect(currentValue, isNull);
expect(headerTapped, false);
await tester.tap(find.text('June 2015'));
expect(headerTapped, true);
});
Future<Null> preparePicker(WidgetTester tester, Future<Null> callback(Future<DateTime> 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<DateTime> 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<DateTime> 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<DateTime> 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<DateTime> date) async {
final TestSemantics expected = new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
flags: <SemanticsFlags>[SemanticsFlags.isSelected],
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'Fri, Jan 15',
textDirection: TextDirection.ltr,
),
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.scrollLeft, SemanticsAction.scrollRight],
children: <TestSemantics>[
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'1, Friday, January 1, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'2, Saturday, January 2, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'3, Sunday, January 3, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'4, Monday, January 4, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'5, Tuesday, January 5, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'6, Wednesday, January 6, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'7, Thursday, January 7, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'8, Friday, January 8, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'9, Saturday, January 9, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'10, Sunday, January 10, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'11, Monday, January 11, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'12, Tuesday, January 12, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'13, Wednesday, January 13, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'14, Thursday, January 14, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
flags: <SemanticsFlags>[SemanticsFlags.isSelected],
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'15, Friday, January 15, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'16, Saturday, January 16, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'17, Sunday, January 17, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'18, Monday, January 18, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'19, Tuesday, January 19, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'20, Wednesday, January 20, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'21, Thursday, January 21, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'22, Friday, January 22, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'23, Saturday, January 23, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'24, Sunday, January 24, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'25, Monday, January 25, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'26, Tuesday, January 26, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'27, Wednesday, January 27, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'28, Thursday, January 28, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'29, Friday, January 29, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'30, Saturday, January 30, 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'31, Sunday, January 31, 2016',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'Previous month December 2015',
textDirection: TextDirection.ltr,
),
new TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'Next month February 2016',
textDirection: TextDirection.ltr,
),
new TestSemantics(
flags: <SemanticsFlags>[SemanticsFlags.isButton],
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'CANCEL',
textDirection: TextDirection.ltr,
),
new TestSemantics(
flags: <SemanticsFlags>[SemanticsFlags.isButton],
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'OK',
textDirection: TextDirection.ltr,
),
],
);
expect(semantics, hasSemantics(
expected,
ignoreId: true,
ignoreTransform: true,
ignoreRect: true,
));
});
});
}
......@@ -46,7 +46,8 @@ class TestSemantics {
this.transform,
this.children: const <TestSemantics>[],
Iterable<SemanticsTag> tags,
}) : assert(flags != null),
}) : assert(flags is int || flags is List<SemanticsFlags>),
assert(actions is int || actions is List<SemanticsAction>),
assert(label != null),
assert(value != null),
assert(increasedValue != null),
......@@ -70,7 +71,8 @@ class TestSemantics {
this.children: const <TestSemantics>[],
Iterable<SemanticsTag> tags,
}) : id = 0,
assert(flags != null),
assert(flags is int || flags is List<SemanticsFlags>),
assert(actions is int || actions is List<SemanticsAction>),
assert(label != null),
assert(increasedValue != null),
assert(decreasedValue != null),
......@@ -103,7 +105,8 @@ class TestSemantics {
Matrix4 transform,
this.children: const <TestSemantics>[],
Iterable<SemanticsTag> tags,
}) : assert(flags != null),
}) : assert(flags is int || flags is List<SemanticsFlags>),
assert(actions is int || actions is List<SemanticsAction>),
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<SemanticsFlags>` that are _on_.
///
/// Using `List<SemanticsFlags>` 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<SemanticsAction>`.
///
/// Using `List<SemanticsAction>` 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<int>(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<int>(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: <SemanticsFlags>[${_flagsToSemanticsFlagsExpression(nodeData.flags)}],');
if (nodeData.actions != 0)
buf.writeln(' actions: <SemanticsAction>[${_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: <TestSemantics>[');
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 {
......
// 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<Null> pumpTestWidget(WidgetTester tester) async {
await tester.pumpWidget(new MaterialApp(
home: new ListView(
children: <Widget>[
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: <TestSemantics>[
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
label: r'Plain text',
textDirection: TextDirection.ltr,
),
new TestSemantics(
flags: <SemanticsFlags>[SemanticsFlags.hasCheckedState, SemanticsFlags.isChecked, SemanticsFlags.isSelected],
actions: <SemanticsAction>[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();
});
}
......@@ -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);
......
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