Unverified Commit b80751cd authored by Yegor's avatar Yegor Committed by GitHub

Make time picker accessible (#13152)

* make time picker accessible

* use new CustomPaint a11y API

* flutter_localizations tests; use bigger distance delta

* fix am/pm control; selected values

* fix translations; remove @mustCallSuper in describeSemanticsConfiguration

* exclude AM/PM announcement from iOS as on iOS the label is read back automatically
parent 927a143d
......@@ -119,6 +119,14 @@ abstract class MaterialLocalizations {
/// The abbreviation for post meridiem (after noon) shown in the time picker.
String get postMeridiemAbbreviation;
/// The text-to-speech announcement made when a time picker invoked using
/// [showTimePicker] is set to the hour picker mode.
String get timePickerHourModeAnnouncement;
/// The text-to-speech announcement made when a time picker invoked using
/// [showTimePicker] is set to the minute picker mode.
String get timePickerMinuteModeAnnouncement;
/// The format used to lay out the time picker.
///
/// The documentation for [TimeOfDayFormat] enum values provides details on
......@@ -505,6 +513,12 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
@override
String get postMeridiemAbbreviation => 'PM';
@override
String get timePickerHourModeAnnouncement => 'Select hours';
@override
String get timePickerMinuteModeAnnouncement => 'Select minutes';
@override
TimeOfDayFormat timeOfDayFormat({ bool alwaysUse24HourFormat: false }) {
return alwaysUse24HourFormat
......
......@@ -147,7 +147,7 @@ class TextField extends StatefulWidget {
///
/// This text style is also used as the base style for the [decoration].
///
/// If null, defaults to a text style from the current [Theme].
/// If null, defaults to the `subhead` text style from the current [Theme].
final TextStyle style;
/// How the text being edited should be aligned horizontally.
......
......@@ -6,6 +6,7 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
......@@ -68,6 +69,7 @@ class _TimePickerFragmentContext {
@required this.inactiveStyle,
@required this.onTimeChange,
@required this.onModeChange,
@required this.targetPlatform,
}) : assert(headerTextTheme != null),
assert(textDirection != null),
assert(selectedTime != null),
......@@ -77,7 +79,8 @@ class _TimePickerFragmentContext {
assert(inactiveColor != null),
assert(inactiveStyle != null),
assert(onTimeChange != null),
assert(onModeChange != null);
assert(onModeChange != null),
assert(targetPlatform != null);
final TextTheme headerTextTheme;
final TextDirection textDirection;
......@@ -89,6 +92,7 @@ class _TimePickerFragmentContext {
final TextStyle inactiveStyle;
final ValueChanged<TimeOfDay> onTimeChange;
final ValueChanged<_TimePickerMode> onModeChange;
final TargetPlatform targetPlatform;
}
/// Contains the [widget] and layout properties of an atom of time information,
......@@ -183,9 +187,30 @@ class _DayPeriodControl extends StatelessWidget {
final _TimePickerFragmentContext fragmentContext;
void _handleChangeDayPeriod() {
void _togglePeriod() {
final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
fragmentContext.onTimeChange(fragmentContext.selectedTime.replacing(hour: newHour));
final TimeOfDay newTime = fragmentContext.selectedTime.replacing(hour: newHour);
fragmentContext.onTimeChange(newTime);
}
void _setAm(BuildContext context) {
if (fragmentContext.selectedTime.period == DayPeriod.am) {
return;
}
if (fragmentContext.targetPlatform == TargetPlatform.android) {
_announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation);
}
_togglePeriod();
}
void _setPm(BuildContext context) {
if (fragmentContext.selectedTime.period == DayPeriod.pm) {
return;
}
if (fragmentContext.targetPlatform == TargetPlatform.android) {
_announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation);
}
_togglePeriod();
}
@override
......@@ -195,25 +220,47 @@ class _DayPeriodControl extends StatelessWidget {
final TimeOfDay selectedTime = fragmentContext.selectedTime;
final Color activeColor = fragmentContext.activeColor;
final Color inactiveColor = fragmentContext.inactiveColor;
final bool amSelected = selectedTime.period == DayPeriod.am;
final TextStyle amStyle = headerTextTheme.subhead.copyWith(
color: selectedTime.period == DayPeriod.am ? activeColor: inactiveColor
color: amSelected ? activeColor: inactiveColor
);
final TextStyle pmStyle = headerTextTheme.subhead.copyWith(
color: selectedTime.period == DayPeriod.pm ? activeColor: inactiveColor
color: !amSelected ? activeColor: inactiveColor
);
return new GestureDetector(
onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context),
behavior: HitTestBehavior.opaque,
child: new Column(
return new Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Text(materialLocalizations.anteMeridiemAbbreviation, style: amStyle),
new GestureDetector(
excludeFromSemantics: true,
onTap: Feedback.wrapForTap(() {
_setAm(context);
}, context),
behavior: HitTestBehavior.opaque,
child: new Semantics(
selected: amSelected,
onTap: () {
_setAm(context);
},
child: new Text(materialLocalizations.anteMeridiemAbbreviation, style: amStyle),
),
),
const SizedBox(width: 0.0, height: 4.0), // Vertical spacer
new Text(materialLocalizations.postMeridiemAbbreviation, style: pmStyle),
],
new GestureDetector(
excludeFromSemantics: true,
onTap: Feedback.wrapForTap(() {
_setPm(context);
}, context),
behavior: HitTestBehavior.opaque,
child: new Semantics(
selected: !amSelected,
onTap: () {
_setPm(context);
},
child: new Text(materialLocalizations.postMeridiemAbbreviation, style: pmStyle),
),
),
],
);
}
}
......@@ -235,13 +282,18 @@ class _HourControl extends StatelessWidget {
final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour
? fragmentContext.activeStyle
: fragmentContext.inactiveStyle;
final String formattedHour = localizations.formatHour(
fragmentContext.selectedTime,
alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
);
return new GestureDetector(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
child: new Text(localizations.formatHour(
fragmentContext.selectedTime,
alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
), style: hourStyle),
child: new Semantics(
selected: fragmentContext.mode == _TimePickerMode.hour,
hint: localizations.timePickerHourModeAnnouncement,
child: new Text(formattedHour, style: hourStyle),
),
);
}
}
......@@ -258,7 +310,9 @@ class _StringFragment extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new Text(value, style: fragmentContext.inactiveStyle);
return new ExcludeSemantics(
child: new Text(value, style: fragmentContext.inactiveStyle),
);
}
}
......@@ -281,7 +335,11 @@ class _MinuteControl extends StatelessWidget {
return new GestureDetector(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
child: new Semantics(
selected: fragmentContext.mode == _TimePickerMode.minute,
hint: localizations.timePickerMinuteModeAnnouncement,
child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle),
),
);
}
}
......@@ -636,6 +694,7 @@ class _TimePickerHeader extends StatelessWidget {
inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor),
onTimeChange: onChanged,
onModeChange: _handleChangeMode,
targetPlatform: themeData.platform,
);
final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext);
......@@ -661,26 +720,28 @@ class _TimePickerHeader extends StatelessWidget {
}
}
List<TextPainter> _buildPainters(TextTheme textTheme, List<String> labels) {
final TextStyle style = textTheme.subhead;
final List<TextPainter> painters = new List<TextPainter>(labels.length);
for (int i = 0; i < painters.length; ++i) {
final String label = labels[i];
// TODO(abarth): Handle textScaleFactor.
// https://github.com/flutter/flutter/issues/5939
painters[i] = new TextPainter(
text: new TextSpan(style: style, text: label),
textDirection: TextDirection.ltr,
)..layout();
}
return painters;
}
enum _DialRing {
outer,
inner,
}
class _TappableLabel {
_TappableLabel({
@required this.value,
@required this.painter,
@required this.onTap,
});
/// The value this label is displaying.
final int value;
/// Paints the text of the label.
final TextPainter painter;
/// Called when a tap gesture is detected on the label.
final VoidCallback onTap;
}
class _DialPainter extends CustomPainter {
const _DialPainter({
@required this.primaryOuterLabels,
......@@ -691,16 +752,20 @@ class _DialPainter extends CustomPainter {
@required this.accentColor,
@required this.theta,
@required this.activeRing,
@required this.textDirection,
@required this.selectedValue,
});
final List<TextPainter> primaryOuterLabels;
final List<TextPainter> primaryInnerLabels;
final List<TextPainter> secondaryOuterLabels;
final List<TextPainter> secondaryInnerLabels;
final List<_TappableLabel> primaryOuterLabels;
final List<_TappableLabel> primaryInnerLabels;
final List<_TappableLabel> secondaryOuterLabels;
final List<_TappableLabel> secondaryInnerLabels;
final Color backgroundColor;
final Color accentColor;
final double theta;
final _DialRing activeRing;
final TextDirection textDirection;
final int selectedValue;
@override
void paint(Canvas canvas, Size size) {
......@@ -726,15 +791,16 @@ class _DialPainter extends CustomPainter {
-labelRadius * math.sin(theta));
}
void paintLabels(List<TextPainter> labels, _DialRing ring) {
void paintLabels(List<_TappableLabel> labels, _DialRing ring) {
if (labels == null)
return;
final double labelThetaIncrement = -_kTwoPi / labels.length;
double labelTheta = math.PI / 2.0;
for (TextPainter label in labels) {
final Offset labelOffset = new Offset(-label.width / 2.0, -label.height / 2.0);
label.paint(canvas, getOffsetForTheta(labelTheta, ring) + labelOffset);
for (_TappableLabel label in labels) {
final TextPainter labelPainter = label.painter;
final Offset labelOffset = new Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0);
labelPainter.paint(canvas, getOffsetForTheta(labelTheta, ring) + labelOffset);
labelTheta += labelThetaIncrement;
}
}
......@@ -762,6 +828,80 @@ class _DialPainter extends CustomPainter {
canvas.restore();
}
static const double _kSemanticNodeSizeScale = 1.5;
@override
SemanticsBuilderCallback get semanticsBuilder => _buildSemantics;
/// Creates semantics nodes for the hour/minute labels painted on the dial.
///
/// The nodes are positioned on top of the text and their size is
/// [_kSemanticNodeSizeScale] bigger than those of the text boxes to provide
/// bigger tap area.
List<CustomPainterSemantics> _buildSemantics(Size size) {
final double radius = size.shortestSide / 2.0;
final Offset center = new Offset(size.width / 2.0, size.height / 2.0);
const double labelPadding = 24.0;
final double outerLabelRadius = radius - labelPadding;
final double innerLabelRadius = radius - labelPadding * 2.5;
Offset getOffsetForTheta(double theta, _DialRing ring) {
double labelRadius;
switch (ring) {
case _DialRing.outer:
labelRadius = outerLabelRadius;
break;
case _DialRing.inner:
labelRadius = innerLabelRadius;
break;
}
return center + new Offset(labelRadius * math.cos(theta),
-labelRadius * math.sin(theta));
}
final List<CustomPainterSemantics> nodes = <CustomPainterSemantics>[];
void paintLabels(List<_TappableLabel> labels, _DialRing ring) {
if (labels == null)
return;
final double labelThetaIncrement = -_kTwoPi / labels.length;
double labelTheta = math.PI / 2.0;
for (_TappableLabel label in labels) {
final TextPainter labelPainter = label.painter;
final double width = labelPainter.width * _kSemanticNodeSizeScale;
final double height = labelPainter.height * _kSemanticNodeSizeScale;
final Offset nodeOffset = getOffsetForTheta(labelTheta, ring) + new Offset(-width / 2.0, -height / 2.0);
final CustomPainterSemantics node = new CustomPainterSemantics(
rect: new Rect.fromLTRB(
nodeOffset.dx,
nodeOffset.dy,
nodeOffset.dx + width,
nodeOffset.dy + height
),
properties: new SemanticsProperties(
selected: label.value == selectedValue,
label: labelPainter.text.text,
textDirection: textDirection,
onTap: label.onTap,
),
tags: new Set<SemanticsTag>.from(const <SemanticsTag>[
// Used by tests to find this node.
const SemanticsTag('dial-label'),
]),
);
nodes.add(node);
labelTheta += labelThetaIncrement;
}
}
paintLabels(primaryOuterLabels, _DialRing.outer);
paintLabels(primaryInnerLabels, _DialRing.inner);
return nodes;
}
@override
bool shouldRepaint(_DialPainter oldPainter) {
return oldPainter.primaryOuterLabels != primaryOuterLabels
......@@ -796,6 +936,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
_updateDialRingFromWidget();
_thetaController = new AnimationController(
duration: _kDialAnimateDuration,
vsync: this,
......@@ -827,8 +968,14 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
if (!_dragging)
_animateTo(_getThetaForTime(widget.selectedTime));
}
if (widget.mode == _TimePickerMode.hour && widget.use24HourDials && widget.selectedTime.period == DayPeriod.am) {
_activeRing = _DialRing.inner;
_updateDialRingFromWidget();
}
void _updateDialRingFromWidget() {
if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
_activeRing = widget.selectedTime.hour >= 1 && widget.selectedTime.hour <= 12
? _DialRing.inner
: _DialRing.outer;
} else {
_activeRing = _DialRing.outer;
}
......@@ -862,9 +1009,9 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
}
double _getThetaForTime(TimeOfDay time) {
final double fraction = (widget.mode == _TimePickerMode.hour) ?
(time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod :
(time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
final double fraction = widget.mode == _TimePickerMode.hour
? (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod
: (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
return (math.PI / 2.0 - fraction * _kTwoPi) % _kTwoPi;
}
......@@ -890,12 +1037,13 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
}
}
void _notifyOnChangedIfNeeded() {
if (widget.onChanged == null)
return;
TimeOfDay _notifyOnChangedIfNeeded() {
final TimeOfDay current = _getTimeForTheta(_theta.value);
if (widget.onChanged == null)
return current;
if (current != widget.selectedTime)
widget.onChanged(current);
return current;
}
void _updateThetaForPan() {
......@@ -944,6 +1092,63 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
_animateTo(_getThetaForTime(widget.selectedTime));
}
void _handleTapUp(TapUpDetails details) {
final RenderBox box = context.findRenderObject();
_position = box.globalToLocal(details.globalPosition);
_center = box.size.center(Offset.zero);
_updateThetaForPan();
final TimeOfDay newTime = _notifyOnChangedIfNeeded();
if (widget.mode == _TimePickerMode.hour) {
if (widget.use24HourDials) {
_announceToAccessibility(context, localizations.formatDecimal(newTime.hour));
} else {
_announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod));
}
} else {
_announceToAccessibility(context, localizations.formatDecimal(newTime.minute));
}
_animateTo(_getThetaForTime(_getTimeForTheta(_theta.value)));
_dragging = false;
_position = null;
_center = null;
}
void _selectHour(int hour) {
_announceToAccessibility(context, localizations.formatDecimal(hour));
TimeOfDay time;
if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
_activeRing = hour >= 1 && hour <= 12
? _DialRing.inner
: _DialRing.outer;
time = new TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
} else {
_activeRing = _DialRing.outer;
if (widget.selectedTime.period == DayPeriod.am) {
time = new TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
} else {
time = new TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute);
}
}
final double angle = _getThetaForTime(time);
_thetaTween
..begin = angle
..end = angle;
_notifyOnChangedIfNeeded();
}
void _selectMinute(int minute) {
_announceToAccessibility(context, localizations.formatDecimal(minute));
final TimeOfDay time = new TimeOfDay(
hour: widget.selectedTime.hour,
minute: minute,
);
final double angle = _getThetaForTime(time);
_thetaTween
..begin = angle
..end = angle;
_notifyOnChangedIfNeeded();
}
static const List<TimeOfDay> _amHours = const <TimeOfDay>[
const TimeOfDay(hour: 12, minute: 0),
const TimeOfDay(hour: 1, minute: 0),
......@@ -974,31 +1179,66 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
const TimeOfDay(hour: 23, minute: 0),
];
List<TextPainter> _build24HourInnerRing(TextTheme textTheme) {
return _buildPainters(textTheme, _amHours
.map((TimeOfDay timeOfDay) {
return localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat);
})
.toList());
_TappableLabel _buildTappableLabel(TextTheme textTheme, int value, String label, VoidCallback onTap) {
final TextStyle style = textTheme.subhead;
// TODO(abarth): Handle textScaleFactor.
// https://github.com/flutter/flutter/issues/5939
return new _TappableLabel(
value: value,
painter: new TextPainter(
text: new TextSpan(style: style, text: label),
textDirection: TextDirection.ltr,
)..layout(),
onTap: onTap,
);
}
List<TextPainter> _build24HourOuterRing(TextTheme textTheme) {
return _buildPainters(textTheme, _pmHours
.map((TimeOfDay timeOfDay) {
return localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat);
})
.toList());
List<_TappableLabel> _build24HourInnerRing(TextTheme textTheme) {
final List<_TappableLabel> labels = <_TappableLabel>[];
for (TimeOfDay timeOfDay in _amHours) {
labels.add(_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
));
}
return labels;
}
List<TextPainter> _build12HourOuterRing(TextTheme textTheme) {
return _buildPainters(textTheme, _amHours
.map((TimeOfDay timeOfDay) {
return localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat);
})
.toList());
List<_TappableLabel> _build24HourOuterRing(TextTheme textTheme) {
final List<_TappableLabel> labels = <_TappableLabel>[];
for (TimeOfDay timeOfDay in _pmHours) {
labels.add(_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
));
}
return labels;
}
List<_TappableLabel> _build12HourOuterRing(TextTheme textTheme) {
final List<_TappableLabel> labels = <_TappableLabel>[];
for (TimeOfDay timeOfDay in _amHours) {
labels.add(_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
));
}
return labels;
}
List<TextPainter> _buildMinutes(TextTheme textTheme) {
List<_TappableLabel> _buildMinutes(TextTheme textTheme) {
const List<TimeOfDay> _minuteMarkerValues = const <TimeOfDay>[
const TimeOfDay(hour: 0, minute: 0),
const TimeOfDay(hour: 0, minute: 5),
......@@ -1014,7 +1254,18 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
const TimeOfDay(hour: 0, minute: 55),
];
return _buildPainters(textTheme, _minuteMarkerValues.map(localizations.formatMinute).toList());
final List<_TappableLabel> labels = <_TappableLabel>[];
for (TimeOfDay timeOfDay in _minuteMarkerValues) {
labels.add(_buildTappableLabel(
textTheme,
timeOfDay.minute,
localizations.formatMinute(timeOfDay),
() {
_selectMinute(timeOfDay.minute);
},
));
}
return labels;
}
@override
......@@ -1030,23 +1281,27 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
}
final ThemeData theme = Theme.of(context);
List<TextPainter> primaryOuterLabels;
List<TextPainter> primaryInnerLabels;
List<TextPainter> secondaryOuterLabels;
List<TextPainter> secondaryInnerLabels;
List<_TappableLabel> primaryOuterLabels;
List<_TappableLabel> primaryInnerLabels;
List<_TappableLabel> secondaryOuterLabels;
List<_TappableLabel> secondaryInnerLabels;
int selectedDialValue;
switch (widget.mode) {
case _TimePickerMode.hour:
if (widget.use24HourDials) {
selectedDialValue = widget.selectedTime.hour;
primaryOuterLabels = _build24HourOuterRing(theme.textTheme);
secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme);
primaryInnerLabels = _build24HourInnerRing(theme.textTheme);
secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme);
} else {
selectedDialValue = widget.selectedTime.hourOfPeriod;
primaryOuterLabels = _build12HourOuterRing(theme.textTheme);
secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme);
}
break;
case _TimePickerMode.minute:
selectedDialValue = widget.selectedTime.minute;
primaryOuterLabels = _buildMinutes(theme.textTheme);
primaryInnerLabels = null;
secondaryOuterLabels = _buildMinutes(theme.accentTextTheme);
......@@ -1055,12 +1310,15 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
}
return new GestureDetector(
excludeFromSemantics: true,
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
onTapUp: _handleTapUp,
child: new CustomPaint(
key: const ValueKey<String>('time-picker-dial'), // used for testing.
key: const ValueKey<String>('time-picker-dial'),
painter: new _DialPainter(
selectedValue: selectedDialValue,
primaryOuterLabels: primaryOuterLabels,
primaryInnerLabels: primaryInnerLabels,
secondaryOuterLabels: secondaryOuterLabels,
......@@ -1069,7 +1327,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
accentColor: themeData.accentColor,
theta: _theta.value,
activeRing: _activeRing,
)
textDirection: Directionality.of(context),
),
)
);
}
......@@ -1105,9 +1364,19 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
_selectedTime = widget.initialTime;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
localizations = MaterialLocalizations.of(context);
_announceInitialTimeOnce();
_announceModeOnce();
}
_TimePickerMode _mode = _TimePickerMode.hour;
_TimePickerMode _lastModeAnnounced;
TimeOfDay _selectedTime;
Timer _vibrateTimer;
MaterialLocalizations localizations;
void _vibrate() {
switch (Theme.of(context).platform) {
......@@ -1128,9 +1397,42 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
_vibrate();
setState(() {
_mode = mode;
_announceModeOnce();
});
}
void _announceModeOnce() {
if (_lastModeAnnounced == _mode) {
// Already announced it.
return;
}
switch (_mode) {
case _TimePickerMode.hour:
_announceToAccessibility(context, localizations.timePickerHourModeAnnouncement);
break;
case _TimePickerMode.minute:
_announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement);
break;
}
_lastModeAnnounced = _mode;
}
bool _announcedInitialTime = false;
void _announceInitialTimeOnce() {
if (_announcedInitialTime)
return;
final MediaQueryData media = MediaQuery.of(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
_announceToAccessibility(
context,
localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
);
_announcedInitialTime = true;
}
void _handleTimeChanged(TimeOfDay value) {
_vibrate();
setState(() {
......@@ -1149,7 +1451,6 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final MediaQueryData media = MediaQuery.of(context);
final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
......@@ -1270,8 +1571,13 @@ Future<TimeOfDay> showTimePicker({
}) async {
assert(context != null);
assert(initialTime != null);
return await showDialog<TimeOfDay>(
context: context,
child: new _TimePickerDialog(initialTime: initialTime),
);
}
void _announceToAccessibility(BuildContext context, String message) {
SemanticsService.announce(message, Directionality.of(context));
}
......@@ -432,6 +432,7 @@ class RenderCustomPaint extends RenderProxyBox {
// Check if we need to rebuild semantics.
if (newPainter == null) {
assert(oldPainter != null); // We should be called only for changes.
if (attached)
markNeedsSemanticsUpdate();
} else if (oldPainter == null ||
newPainter.runtimeType != oldPainter.runtimeType ||
......
......@@ -822,7 +822,8 @@ class PipelineOwner {
/// objects for a given [PipelineOwner] are closed, the [PipelineOwner] stops
/// maintaining the semantics tree.
SemanticsHandle ensureSemantics({ VoidCallback listener }) {
if (_outstandingSemanticsHandle++ == 0) {
_outstandingSemanticsHandle += 1;
if (_outstandingSemanticsHandle == 1) {
assert(_semanticsOwner == null);
_semanticsOwner = new SemanticsOwner();
if (onSemanticsOwnerCreated != null)
......@@ -833,7 +834,8 @@ class PipelineOwner {
void _didDisposeSemanticsHandle() {
assert(_semanticsOwner != null);
if (--_outstandingSemanticsHandle == 0) {
_outstandingSemanticsHandle -= 1;
if (_outstandingSemanticsHandle == 0) {
_semanticsOwner.dispose();
_semanticsOwner = null;
if (onSemanticsOwnerDisposed != null)
......
......@@ -2583,8 +2583,8 @@ class RenderSemanticsGestureHandler extends RenderProxyBox {
/// purposes.
///
/// If this tag is used, the first "outer" semantics node is the regular node
/// of this object. The second "inner" node is introduces as a child to that
/// node. All scrollable children are now a child of the inner node, which has
/// of this object. The second "inner" node is introduced as a child to that
/// node. All scrollable children become children of the inner node, which has
/// the semantic scrolling logic enabled. All children that have been
/// excluded from scrolling with [excludeFromScrolling] are turned into
/// children of the outer node.
......@@ -3204,6 +3204,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = container;
config.explicitChildNodes = explicitChildNodes;
......
......@@ -325,7 +325,7 @@ class SemanticsProperties extends DiagnosticableTree {
/// Provides a brief textual description of the result of an action performed
/// on the widget.
///
/// If a hint is provided, there must either by an ambient [Directionality]
/// If a hint is provided, there must either be an ambient [Directionality]
/// or an explicit [textDirection] should be provided.
///
/// See also:
......@@ -889,7 +889,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
static final SemanticsConfiguration _kEmptyConfig = new SemanticsConfiguration();
/// Reconfigures the properties of this object to describe the configuration
/// provided in the `config` argument and the children listen in the
/// provided in the `config` argument and the children listed in the
/// `childrenInInversePaintOrder` argument.
///
/// The arguments may be null; this represents an empty configuration (all
......@@ -899,7 +899,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
/// list is used as-is and should therefore not be changed after this call.
void updateWith({
@required SemanticsConfiguration config,
@required List<SemanticsNode> childrenInInversePaintOrder,
List<SemanticsNode> childrenInInversePaintOrder,
}) {
config ??= _kEmptyConfig;
if (_isDifferentFromCurrentSemanticAnnotation(config))
......@@ -1338,7 +1338,7 @@ class SemanticsConfiguration {
/// create semantic boundaries that are either writable or not for children.
bool explicitChildNodes = false;
/// Whether the owning [RenderObject] makes other [RenderObjects] previously
/// Whether the owning [RenderObject] makes other [RenderObject]s previously
/// painted within the same semantic boundary unreachable for accessibility
/// purposes.
///
......
......@@ -5,8 +5,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// An event that can be send by the application to notify interested listeners
/// that something happened to the user interface (e.g. a view scrolled).
/// An event sent by the application to notify interested listeners that
/// something happened to the user interface (e.g. a view scrolled).
///
/// These events are usually interpreted by assistive technologies to give the
/// user additional clues about the current state of the UI.
......
......@@ -4922,7 +4922,7 @@ class BlockSemantics extends SingleChildRenderObjectWidget {
/// When [excluding] is true, this widget (and its subtree) is excluded from
/// the semantics tree.
///
/// This can be used to hide subwidgets that would otherwise be
/// This can be used to hide descendant widgets that would otherwise be
/// reported but that would only be confusing. For example, the
/// material library's [Chip] widget hides the avatar since it is
/// redundant with the chip label.
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
......@@ -10,6 +11,9 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
import '../rendering/recording_canvas.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
class _TimePickerLauncher extends StatelessWidget {
......@@ -47,7 +51,7 @@ Future<Offset> startPicker(WidgetTester tester, ValueChanged<TimeOfDay> onChange
await tester.pumpWidget(new _TimePickerLauncher(onChanged: onChanged, locale: const Locale('en', 'US')));
await tester.tap(find.text('X'));
await tester.pumpAndSettle(const Duration(seconds: 1));
return tester.getCenter(find.byKey(const Key('time-picker-dial')));
return tester.getCenter(find.byKey(const ValueKey<String>('time-picker-dial')));
}
Future<Null> finishPicker(WidgetTester tester) async {
......@@ -57,6 +61,12 @@ Future<Null> finishPicker(WidgetTester tester) async {
}
void main() {
group('Time picker', () {
_tests();
});
}
void _tests() {
testWidgets('tap-select an hour', (WidgetTester tester) async {
TimeOfDay result;
......@@ -210,7 +220,8 @@ void main() {
const List<String> labels12To11TwoDigit = const <String>['12', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11'];
const List<String> labels00To23 = const <String>['00', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'];
Future<Null> mediaQueryBoilerplate(WidgetTester tester, bool alwaysUse24HourFormat) async {
Future<Null> mediaQueryBoilerplate(WidgetTester tester, bool alwaysUse24HourFormat,
{ TimeOfDay initialTime: const TimeOfDay(hour: 7, minute: 0) }) async {
await tester.pumpWidget(
new Localizations(
locale: const Locale('en', 'US'),
......@@ -220,57 +231,235 @@ void main() {
],
child: new MediaQuery(
data: new MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat),
child: new Material(
child: new Directionality(
textDirection: TextDirection.ltr,
child: new Navigator(
onGenerateRoute: (RouteSettings settings) {
return new MaterialPageRoute<dynamic>(builder: (BuildContext context) {
showTimePicker(context: context, initialTime: const TimeOfDay(hour: 7, minute: 0));
return new Container();
return new FlatButton(
onPressed: () {
showTimePicker(context: context, initialTime: initialTime);
},
child: const Text('X'),
);
});
},
),
),
),
),
),
);
// Pump once, because the dialog shows up asynchronously.
await tester.pump();
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
}
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false);
final CustomPaint dialPaint = tester.widget(find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byType(CustomPaint),
));
final CustomPaint dialPaint = tester.widget(findDialPaint);
final dynamic dialPainter = dialPaint.painter;
final List<TextPainter> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map((TextPainter tp) => tp.text.text), labels12To11);
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11);
expect(dialPainter.primaryInnerLabels, null);
final List<TextPainter> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map((TextPainter tp) => tp.text.text), labels12To11);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11);
expect(dialPainter.secondaryInnerLabels, null);
});
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, true);
final CustomPaint dialPaint = tester.widget(find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byType(CustomPaint),
));
final CustomPaint dialPaint = tester.widget(findDialPaint);
final dynamic dialPainter = dialPaint.painter;
final List<TextPainter> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map((TextPainter tp) => tp.text.text), labels00To23);
final List<TextPainter> primaryInnerLabels = dialPainter.primaryInnerLabels;
expect(primaryInnerLabels.map((TextPainter tp) => tp.text.text), labels12To11TwoDigit);
final List<TextPainter> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map((TextPainter tp) => tp.text.text), labels00To23);
final List<TextPainter> secondaryInnerLabels = dialPainter.secondaryInnerLabels;
expect(secondaryInnerLabels.map((TextPainter tp) => tp.text.text), labels12To11TwoDigit);
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels00To23);
final List<dynamic> primaryInnerLabels = dialPainter.primaryInnerLabels;
expect(primaryInnerLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11TwoDigit);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels00To23);
final List<dynamic> secondaryInnerLabels = dialPainter.secondaryInnerLabels;
expect(secondaryInnerLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11TwoDigit);
});
testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await mediaQueryBoilerplate(tester, false);
expect(semantics, includesNodeWith(label: 'AM', actions: <SemanticsAction>[SemanticsAction.tap]));
expect(semantics, includesNodeWith(label: 'PM', actions: <SemanticsAction>[SemanticsAction.tap]));
semantics.dispose();
});
testWidgets('provides semantics information for header and footer', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await mediaQueryBoilerplate(tester, true);
expect(semantics, isNot(includesNodeWith(label: ':')));
expect(semantics.nodesWith(label: '00'), hasLength(2),
reason: '00 appears once in the header, then again in the dial');
expect(semantics.nodesWith(label: '07'), hasLength(2),
reason: '07 appears once in the header, then again in the dial');
expect(semantics, includesNodeWith(label: 'CANCEL'));
expect(semantics, includesNodeWith(label: 'OK'));
// In 24-hour mode we don't have AM/PM control.
expect(semantics, isNot(includesNodeWith(label: 'AM')));
expect(semantics, isNot(includesNodeWith(label: 'PM')));
semantics.dispose();
});
testWidgets('provides semantics information for hours', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await mediaQueryBoilerplate(tester, true);
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
final CustomPainter dialPainter = dialPaint.painter;
final _CustomPainterSemanticsTester painterTester = new _CustomPainterSemanticsTester(tester, dialPainter, semantics);
painterTester.addLabel('00', 86.0, 12.0, 134.0, 36.0);
painterTester.addLabel('13', 129.0, 23.5, 177.0, 47.5);
painterTester.addLabel('14', 160.5, 55.0, 208.5, 79.0);
painterTester.addLabel('15', 172.0, 98.0, 220.0, 122.0);
painterTester.addLabel('16', 160.5, 141.0, 208.5, 165.0);
painterTester.addLabel('17', 129.0, 172.5, 177.0, 196.5);
painterTester.addLabel('18', 86.0, 184.0, 134.0, 208.0);
painterTester.addLabel('19', 43.0, 172.5, 91.0, 196.5);
painterTester.addLabel('20', 11.5, 141.0, 59.5, 165.0);
painterTester.addLabel('21', 0.0, 98.0, 48.0, 122.0);
painterTester.addLabel('22', 11.5, 55.0, 59.5, 79.0);
painterTester.addLabel('23', 43.0, 23.5, 91.0, 47.5);
painterTester.addLabel('12', 86.0, 48.0, 134.0, 72.0);
painterTester.addLabel('01', 111.0, 54.7, 159.0, 78.7);
painterTester.addLabel('02', 129.3, 73.0, 177.3, 97.0);
painterTester.addLabel('03', 136.0, 98.0, 184.0, 122.0);
painterTester.addLabel('04', 129.3, 123.0, 177.3, 147.0);
painterTester.addLabel('05', 111.0, 141.3, 159.0, 165.3);
painterTester.addLabel('06', 86.0, 148.0, 134.0, 172.0);
painterTester.addLabel('07', 61.0, 141.3, 109.0, 165.3);
painterTester.addLabel('08', 42.7, 123.0, 90.7, 147.0);
painterTester.addLabel('09', 36.0, 98.0, 84.0, 122.0);
painterTester.addLabel('10', 42.7, 73.0, 90.7, 97.0);
painterTester.addLabel('11', 61.0, 54.7, 109.0, 78.7);
painterTester.assertExpectations();
semantics.dispose();
});
testWidgets('provides semantics information for minutes', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await mediaQueryBoilerplate(tester, true);
await tester.tap(find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_MinuteControl'));
await tester.pumpAndSettle();
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
final CustomPainter dialPainter = dialPaint.painter;
final _CustomPainterSemanticsTester painterTester = new _CustomPainterSemanticsTester(tester, dialPainter, semantics);
painterTester.addLabel('00', 86.0, 12.0, 134.0, 36.0);
painterTester.addLabel('05', 129.0, 23.5, 177.0, 47.5);
painterTester.addLabel('10', 160.5, 55.0, 208.5, 79.0);
painterTester.addLabel('15', 172.0, 98.0, 220.0, 122.0);
painterTester.addLabel('20', 160.5, 141.0, 208.5, 165.0);
painterTester.addLabel('25', 129.0, 172.5, 177.0, 196.5);
painterTester.addLabel('30', 86.0, 184.0, 134.0, 208.0);
painterTester.addLabel('35', 43.0, 172.5, 91.0, 196.5);
painterTester.addLabel('40', 11.5, 141.0, 59.5, 165.0);
painterTester.addLabel('45', 0.0, 98.0, 48.0, 122.0);
painterTester.addLabel('50', 11.5, 55.0, 59.5, 79.0);
painterTester.addLabel('55', 43.0, 23.5, 91.0, 47.5);
painterTester.assertExpectations();
semantics.dispose();
});
testWidgets('picks the right dial ring from widget configuration', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, true, initialTime: const TimeOfDay(hour: 12, minute: 0));
dynamic dialPaint = tester.widget(findDialPaint);
expect('${dialPaint.painter.activeRing}', '_DialRing.inner');
await tester.pumpWidget(new Container()); // make sure previous state isn't reused
await mediaQueryBoilerplate(tester, true, initialTime: const TimeOfDay(hour: 0, minute: 0));
dialPaint = tester.widget(findDialPaint);
expect('${dialPaint.painter.activeRing}', '_DialRing.outer');
});
}
final Finder findDialPaint = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byWidgetPredicate((Widget w) => w is CustomPaint),
);
class _SemanticsNodeExpectation {
final String label;
final double left;
final double top;
final double right;
final double bottom;
_SemanticsNodeExpectation(this.label, this.left, this.top, this.right, this.bottom);
}
class _CustomPainterSemanticsTester {
_CustomPainterSemanticsTester(this.tester, this.painter, this.semantics);
final WidgetTester tester;
final CustomPainter painter;
final SemanticsTester semantics;
final PaintPattern expectedLabels = paints;
final List<_SemanticsNodeExpectation> expectedNodes = <_SemanticsNodeExpectation>[];
void addLabel(String label, double left, double top, double right, double bottom) {
expectedNodes.add(new _SemanticsNodeExpectation(label, left, top, right, bottom));
}
void assertExpectations() {
final TestRecordingCanvas canvasRecording = new TestRecordingCanvas();
painter.paint(canvasRecording, const Size(220.0, 220.0));
final List<ui.Paragraph> paragraphs = canvasRecording.invocations
.where((RecordedInvocation recordedInvocation) {
return recordedInvocation.invocation.memberName == #drawParagraph;
})
.map<ui.Paragraph>((RecordedInvocation recordedInvocation) {
return recordedInvocation.invocation.positionalArguments.first;
})
.toList();
final PaintPattern expectedLabels = paints;
int i = 0;
for (_SemanticsNodeExpectation expectation in expectedNodes) {
expect(semantics, includesNodeWith(label: expectation.label));
final Iterable<SemanticsNode> dialLabelNodes = semantics
.nodesWith(label: expectation.label)
.where((SemanticsNode node) => node.tags?.contains(const SemanticsTag('dial-label')) ?? false);
expect(dialLabelNodes, hasLength(1), reason: 'Expected exactly one label ${expectation.label}');
final Rect rect = new Rect.fromLTRB(expectation.left, expectation.top, expectation.right, expectation.bottom);
expect(dialLabelNodes.single.rect, within(distance: 1.0, from: rect),
reason: 'This is checking the node rectangle for label ${expectation.label}');
final ui.Paragraph paragraph = paragraphs[i++];
// The label text paragraph and the semantics node share the same center,
// but have different sizes.
final Offset center = dialLabelNodes.single.rect.center;
final Offset topLeft = center.translate(
-paragraph.width / 2.0,
-paragraph.height / 2.0,
);
expectedLabels.paragraph(
paragraph: paragraph,
offset: within<Offset>(distance: 1.0, from: topLeft),
);
}
expect(tester.renderObject(findDialPaint), expectedLabels);
}
}
......@@ -282,8 +282,15 @@ abstract class PaintPattern {
/// arguments that are passed to this method are compared to the actual
/// [Canvas.drawParagraph] call's argument, and any mismatches result in failure.
///
/// The `offset` argument can be either an [Offset] or a [Matcher]. If it is
/// an [Offset] then the actual value must match the expected offset
/// precisely. If it is a [Matcher] then the comparison is made according to
/// the semantics of the [Matcher]. For example, [within] can be used to
/// assert that the actual offset is within a given distance from the expected
/// offset.
///
/// If no call to [Canvas.drawParagraph] was made, then this results in failure.
void paragraph({ ui.Paragraph paragraph, Offset offset });
void paragraph({ ui.Paragraph paragraph, dynamic offset });
/// Indicates that an image is expected next.
///
......@@ -626,7 +633,7 @@ class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher imp
}
@override
void paragraph({ ui.Paragraph paragraph, Offset offset }) {
void paragraph({ ui.Paragraph paragraph, dynamic offset }) {
_predicates.add(new _FunctionPaintPredicate(#drawParagraph, <dynamic>[paragraph, offset]));
}
......@@ -1140,9 +1147,13 @@ class _FunctionPaintPredicate extends _PaintPredicate {
for (int index = 0; index < arguments.length; index += 1) {
final dynamic actualArgument = call.current.invocation.positionalArguments[index];
final dynamic desiredArgument = arguments[index];
if (desiredArgument != null && desiredArgument != actualArgument)
if (desiredArgument is Matcher) {
expect(actualArgument, desiredArgument);
} else if (desiredArgument != null && desiredArgument != actualArgument) {
throw 'It called ${_symbolName(symbol)} with argument $index having value ${_valueName(actualArgument)} when ${_valueName(desiredArgument)} was expected.';
}
}
call.moveNext();
}
......
......@@ -299,6 +299,47 @@ class SemanticsTester {
@override
String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode}';
Iterable<SemanticsNode> nodesWith({
String label,
String value,
TextDirection textDirection,
List<SemanticsAction> actions,
List<SemanticsFlags> flags,
}) {
bool checkNode(SemanticsNode node) {
if (label != null && node.label != label)
return false;
if (value != null && node.value != value)
return false;
if (textDirection != null && node.textDirection != textDirection)
return false;
if (actions != null) {
final int expectedActions = actions.fold(0, (int value, SemanticsAction action) => value | action.index);
final int actualActions = node.getSemanticsData().actions;
if (expectedActions != actualActions)
return false;
}
if (flags != null) {
final int expectedFlags = flags.fold(0, (int value, SemanticsFlags flag) => value | flag.index);
final int actualFlags = node.getSemanticsData().flags;
if (expectedFlags != actualFlags)
return false;
}
return true;
}
final List<SemanticsNode> result = <SemanticsNode>[];
bool visit(SemanticsNode node) {
if (checkNode(node)) {
result.add(node);
}
node.visitChildren(visit);
return true;
}
visit(tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode);
return result;
}
}
class _HasSemantics extends Matcher {
......@@ -354,41 +395,13 @@ class _IncludesNodeWith extends Matcher {
@override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
bool result = false;
SemanticsNodeVisitor visitor;
visitor = (SemanticsNode node) {
if (checkNode(node)) {
result = true;
} else {
node.visitChildren(visitor);
}
return !result;
};
final SemanticsNode root = item.tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode;
visitor(root);
return result;
}
bool checkNode(SemanticsNode node) {
if (label != null && node.label != label)
return false;
if (value != null && node.value != value)
return false;
if (textDirection != null && node.textDirection != textDirection)
return false;
if (actions != null) {
final int expectedActions = actions.fold(0, (int value, SemanticsAction action) => value | action.index);
final int actualActions = node.getSemanticsData().actions;
if (expectedActions != actualActions)
return false;
}
if (flags != null) {
final int expectedFlags = flags.fold(0, (int value, SemanticsFlags flag) => value | flag.index);
final int actualFlags = node.getSemanticsData().flags;
if (expectedFlags != actualFlags)
return false;
}
return true;
return item.nodesWith(
label: label,
value: value,
textDirection: textDirection,
actions: actions,
flags: flags,
).isNotEmpty;
}
@override
......
......@@ -45,6 +45,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'الاطّلاع على التراخيص',
'anteMeridiemAbbreviation': r'ص',
'postMeridiemAbbreviation': r'م',
'timePickerHourModeAnnouncement': r'حدد ساعات',
'timePickerMinuteModeAnnouncement': r'حدد دقائق',
},
'de': const <String, String>{
'scriptCategory': r'English-like',
......@@ -77,6 +79,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'LIZENZEN ANZEIGEN',
'anteMeridiemAbbreviation': r'VORM.',
'postMeridiemAbbreviation': r'NACHM.',
'timePickerHourModeAnnouncement': r'Stunde auswählen',
'timePickerMinuteModeAnnouncement': r'Minute auswählen',
},
'de_CH': const <String, String>{
'scriptCategory': r'English-like',
......@@ -140,6 +144,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'VIEW LICENSES',
'anteMeridiemAbbreviation': r'AM',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'Select hours',
'timePickerMinuteModeAnnouncement': r'Select minutes',
},
'en_AU': const <String, String>{
'scriptCategory': r'English-like',
......@@ -389,6 +395,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'VER LICENCIAS',
'anteMeridiemAbbreviation': r'A.M.',
'postMeridiemAbbreviation': r'P.M.',
'timePickerHourModeAnnouncement': r'Seleccione Horas',
'timePickerMinuteModeAnnouncement': r'Seleccione Minutos',
},
'es_US': const <String, String>{
'scriptCategory': r'English-like',
......@@ -426,6 +434,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'مشاهده مجوزها',
'anteMeridiemAbbreviation': r'ق.ظ.',
'postMeridiemAbbreviation': r'ب.ظ.',
'timePickerHourModeAnnouncement': r'ساعت ها را انتخاب کنید',
'timePickerMinuteModeAnnouncement': r'دقیقه را انتخاب کنید',
},
'fr': const <String, String>{
'scriptCategory': r'English-like',
......@@ -458,6 +468,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'AFFICHER LES LICENCES',
'anteMeridiemAbbreviation': r'AM',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'Sélectionnez les heures',
'timePickerMinuteModeAnnouncement': r'Sélectionnez les minutes',
},
'fr_CA': const <String, String>{
'scriptCategory': r'English-like',
......@@ -526,6 +538,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'הצגת הרישיונות',
'anteMeridiemAbbreviation': r'AM',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'בחר שעות',
'timePickerMinuteModeAnnouncement': r'בחר דקות',
},
'it': const <String, String>{
'scriptCategory': r'English-like',
......@@ -557,6 +571,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'VISUALIZZA LICENZE',
'anteMeridiemAbbreviation': r'AM',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'Seleziona ore',
'timePickerMinuteModeAnnouncement': r'Seleziona minuti',
},
'ja': const <String, String>{
'scriptCategory': r'dense',
......@@ -588,6 +604,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'ライセンスを表示',
'anteMeridiemAbbreviation': r'AM',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'時を選択',
'timePickerMinuteModeAnnouncement': r'分を選択',
},
'ps': const <String, String>{
'scriptCategory': r'tall',
......@@ -616,6 +634,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'pasteButtonLabel': r'پیټ کړئ',
'selectAllButtonLabel': r'غوره کړئ',
'viewLicensesButtonLabel': r'لیدلس وګورئ',
'timePickerHourModeAnnouncement': r'وختونه وټاکئ',
'timePickerMinuteModeAnnouncement': r'منې غوره کړئ',
},
'pt': const <String, String>{
'scriptCategory': r'English-like',
......@@ -644,6 +664,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'pasteButtonLabel': r'COLAR',
'selectAllButtonLabel': r'SELECIONAR TUDO',
'viewLicensesButtonLabel': r'VER LICENÇAS',
'timePickerHourModeAnnouncement': r'Selecione horários',
'timePickerMinuteModeAnnouncement': r'Selecione Minutos',
},
'pt_PT': const <String, String>{
'scriptCategory': r'English-like',
......@@ -709,6 +731,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'ЛИЦЕНЗИИ',
'anteMeridiemAbbreviation': r'АМ',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'ВЫБРАТЬ ЧАСЫ',
'timePickerMinuteModeAnnouncement': r'ВЫБРАТЬ МИНУТЫ',
},
'ur': const <String, String>{
'scriptCategory': r'tall',
......@@ -740,6 +764,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'viewLicensesButtonLabel': r'لائسنسز دیکھیں',
'anteMeridiemAbbreviation': r'AM',
'postMeridiemAbbreviation': r'PM',
'timePickerHourModeAnnouncement': r'گھنٹے منتخب کریں',
'timePickerMinuteModeAnnouncement': r'منٹ منتخب کریں',
},
'zh': const <String, String>{
'scriptCategory': r'dense',
......@@ -771,6 +797,8 @@ const Map<String, Map<String, String>> localizations = const <String, Map<String
'previousMonthTooltip': r'上个月',
'anteMeridiemAbbreviation': r'上午',
'postMeridiemAbbreviation': r'下午',
'timePickerHourModeAnnouncement': r'选择小时',
'timePickerMinuteModeAnnouncement': r'选择分钟',
},
};
......@@ -31,5 +31,7 @@
"selectAllButtonLabel": "اختيار الكل",
"viewLicensesButtonLabel": "الاطّلاع على التراخيص",
"anteMeridiemAbbreviation": "ص",
"postMeridiemAbbreviation": "م"
"postMeridiemAbbreviation": "م",
"timePickerHourModeAnnouncement": "حدد ساعات",
"timePickerMinuteModeAnnouncement": "حدد دقائق"
}
......@@ -28,5 +28,7 @@
"selectAllButtonLabel": "ALLE AUSWÄHLEN",
"viewLicensesButtonLabel": "LIZENZEN ANZEIGEN",
"anteMeridiemAbbreviation": "VORM.",
"postMeridiemAbbreviation": "NACHM."
"postMeridiemAbbreviation": "NACHM.",
"timePickerHourModeAnnouncement": "Stunde auswählen",
"timePickerMinuteModeAnnouncement": "Minute auswählen"
}
......@@ -143,5 +143,15 @@
"postMeridiemAbbreviation": "PM",
"@postMeridiemAbbreviation": {
"description": "The abbreviation for post meridiem (after noon) shown in the time picker. Translations for this abbreviation will only be provided for locales that support it."
},
"timePickerHourModeAnnouncement": "Select hours",
"@timePickerHourModeAnnouncement": {
"description": "The audio announcement made when the time picker dialog is set to hour mode."
},
"timePickerMinuteModeAnnouncement": "Select minutes",
"@timePickerMinuteModeAnnouncement": {
"description": "The audio announcement made when the time picker dialog is set to minute mode."
}
}
......@@ -28,5 +28,7 @@
"selectAllButtonLabel": "SELECCIONAR TODO",
"viewLicensesButtonLabel": "VER LICENCIAS",
"anteMeridiemAbbreviation": "A.M.",
"postMeridiemAbbreviation": "P.M."
"postMeridiemAbbreviation": "P.M.",
"timePickerHourModeAnnouncement": "Seleccione Horas",
"timePickerMinuteModeAnnouncement": "Seleccione Minutos"
}
......@@ -27,5 +27,7 @@
"selectAllButtonLabel": "انتخاب همه",
"viewLicensesButtonLabel": "مشاهده مجوزها",
"anteMeridiemAbbreviation": "ق.ظ.",
"postMeridiemAbbreviation": "ب.ظ."
"postMeridiemAbbreviation": "ب.ظ.",
"timePickerHourModeAnnouncement": "ساعت ها را انتخاب کنید",
"timePickerMinuteModeAnnouncement": "دقیقه را انتخاب کنید"
}
......@@ -28,5 +28,7 @@
"selectAllButtonLabel": "TOUT SÉLECTIONNER",
"viewLicensesButtonLabel": "AFFICHER LES LICENCES",
"anteMeridiemAbbreviation": "AM",
"postMeridiemAbbreviation": "PM"
"postMeridiemAbbreviation": "PM",
"timePickerHourModeAnnouncement": "Sélectionnez les heures",
"timePickerMinuteModeAnnouncement": "Sélectionnez les minutes"
}
......@@ -29,5 +29,7 @@
"selectAllButtonLabel": "בחירת הכול",
"viewLicensesButtonLabel": "הצגת הרישיונות",
"anteMeridiemAbbreviation": "AM",
"postMeridiemAbbreviation": "PM"
"postMeridiemAbbreviation": "PM",
"timePickerHourModeAnnouncement": "בחר שעות",
"timePickerMinuteModeAnnouncement": "בחר דקות"
}
......@@ -27,5 +27,7 @@
"selectAllButtonLabel": "SELEZIONA TUTTO",
"viewLicensesButtonLabel": "VISUALIZZA LICENZE",
"anteMeridiemAbbreviation": "AM",
"postMeridiemAbbreviation": "PM"
"postMeridiemAbbreviation": "PM",
"timePickerHourModeAnnouncement": "Seleziona ore",
"timePickerMinuteModeAnnouncement": "Seleziona minuti"
}
......@@ -27,5 +27,7 @@
"selectAllButtonLabel": "すべて選択",
"viewLicensesButtonLabel": "ライセンスを表示",
"anteMeridiemAbbreviation": "AM",
"postMeridiemAbbreviation": "PM"
"postMeridiemAbbreviation": "PM",
"timePickerHourModeAnnouncement": "時を選択",
"timePickerMinuteModeAnnouncement": "分を選択"
}
......@@ -26,5 +26,7 @@
"okButtonLabel": "سمه ده",
"pasteButtonLabel": "پیټ کړئ",
"selectAllButtonLabel": "غوره کړئ",
"viewLicensesButtonLabel": "لیدلس وګورئ"
"viewLicensesButtonLabel": "لیدلس وګورئ",
"timePickerHourModeAnnouncement": "وختونه وټاکئ",
"timePickerMinuteModeAnnouncement": "منې غوره کړئ"
}
......@@ -26,5 +26,7 @@
"okButtonLabel": "OK",
"pasteButtonLabel": "COLAR",
"selectAllButtonLabel": "SELECIONAR TUDO",
"viewLicensesButtonLabel": "VER LICENÇAS"
"viewLicensesButtonLabel": "VER LICENÇAS",
"timePickerHourModeAnnouncement": "Selecione horários",
"timePickerMinuteModeAnnouncement": "Selecione Minutos"
}
......@@ -30,5 +30,7 @@
"selectAllButtonLabel": "ВЫБРАТЬ ВСЕ",
"viewLicensesButtonLabel": "ЛИЦЕНЗИИ",
"anteMeridiemAbbreviation": "АМ",
"postMeridiemAbbreviation": "PM"
"postMeridiemAbbreviation": "PM",
"timePickerHourModeAnnouncement": "ВЫБРАТЬ ЧАСЫ",
"timePickerMinuteModeAnnouncement": "ВЫБРАТЬ МИНУТЫ"
}
......@@ -27,5 +27,7 @@
"selectAllButtonLabel": "سبھی منتخب کریں",
"viewLicensesButtonLabel": "لائسنسز دیکھیں",
"anteMeridiemAbbreviation": "AM",
"postMeridiemAbbreviation": "PM"
"postMeridiemAbbreviation": "PM",
"timePickerHourModeAnnouncement": "گھنٹے منتخب کریں",
"timePickerMinuteModeAnnouncement": "منٹ منتخب کریں"
}
......@@ -27,5 +27,7 @@
"nextMonthTooltip": "下个月",
"previousMonthTooltip": "上个月",
"anteMeridiemAbbreviation": "上午",
"postMeridiemAbbreviation": "下午"
"postMeridiemAbbreviation": "下午",
"timePickerHourModeAnnouncement": "选择小时",
"timePickerMinuteModeAnnouncement": "选择分钟"
}
......@@ -316,6 +316,12 @@ class GlobalMaterialLocalizations implements MaterialLocalizations {
@override
String get postMeridiemAbbreviation => _nameToValue['postMeridiemAbbreviation'];
@override
String get timePickerHourModeAnnouncement => _nameToValue['timePickerHourModeAnnouncement'];
@override
String get timePickerMinuteModeAnnouncement => _nameToValue['timePickerMinuteModeAnnouncement'];
/// The [TimeOfDayFormat] corresponding to one of the following supported
/// patterns:
///
......
......@@ -140,57 +140,58 @@ void main() {
],
child: new MediaQuery(
data: new MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat),
child: new Material(
child: new Directionality(
textDirection: TextDirection.ltr,
child: new Navigator(
onGenerateRoute: (RouteSettings settings) {
return new MaterialPageRoute<dynamic>(builder: (BuildContext context) {
return new FlatButton(
onPressed: () {
showTimePicker(context: context, initialTime: const TimeOfDay(hour: 7, minute: 0));
return new Container();
},
child: const Text('X'),
);
});
},
),
),
),
),
),
);
// Pump once, because the dialog shows up asynchronously.
await tester.pump();
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
}
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false);
final CustomPaint dialPaint = tester.widget(find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byType(CustomPaint),
));
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
final dynamic dialPainter = dialPaint.painter;
final List<TextPainter> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map((TextPainter tp) => tp.text.text), labels12To11);
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11);
expect(dialPainter.primaryInnerLabels, null);
final List<TextPainter> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map((TextPainter tp) => tp.text.text), labels12To11);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11);
expect(dialPainter.secondaryInnerLabels, null);
});
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, true);
final CustomPaint dialPaint = tester.widget(find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byType(CustomPaint),
));
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
final dynamic dialPainter = dialPaint.painter;
final List<TextPainter> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map((TextPainter tp) => tp.text.text), labels00To23);
final List<TextPainter> primaryInnerLabels = dialPainter.primaryInnerLabels;
expect(primaryInnerLabels.map((TextPainter tp) => tp.text.text), labels12To11TwoDigit);
final List<TextPainter> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map((TextPainter tp) => tp.text.text), labels00To23);
final List<TextPainter> secondaryInnerLabels = dialPainter.secondaryInnerLabels;
expect(secondaryInnerLabels.map((TextPainter tp) => tp.text.text), labels12To11TwoDigit);
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels;
expect(primaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels00To23);
final List<dynamic> primaryInnerLabels = dialPainter.primaryInnerLabels;
expect(primaryInnerLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11TwoDigit);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels;
expect(secondaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text), labels00To23);
final List<dynamic> secondaryInnerLabels = dialPainter.secondaryInnerLabels;
expect(secondaryInnerLabels.map<String>((dynamic tp) => tp.painter.text.text), labels12To11TwoDigit);
});
}
......@@ -597,6 +597,7 @@ const Map<Type, DistanceFunction<dynamic>> _kStandardDistanceFunctions = const <
Offset: _offsetDistance,
int: _intDistance,
double: _doubleDistance,
Rect: _rectDistance,
};
int _intDistance(int a, int b) => (b - a).abs();
......@@ -610,6 +611,13 @@ double _maxComponentColorDistance(Color a, Color b) {
return delta.toDouble();
}
double _rectDistance(Rect a, Rect b) {
double delta = math.max<double>((a.left - b.left).abs(), (a.top - b.top).abs());
delta = math.max<double>(delta, (a.right - b.right).abs());
delta = math.max<double>(delta, (a.bottom - b.bottom).abs());
return delta;
}
/// Asserts that two values are within a certain distance from each other.
///
/// The distance is computed by a [DistanceFunction].
......@@ -669,11 +677,23 @@ class _IsWithinDistance<T> extends Matcher {
'double value, but it returned $distance.'
);
}
matchState['distance'] = distance;
return distance <= epsilon;
}
@override
Description describe(Description description) => description.add('$value$epsilon)');
@override
Description describeMismatch(
Object object,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose,
) {
mismatchDescription.add('was ${matchState['distance']} away from the desired value.');
return mismatchDescription;
}
}
class _MoreOrLessEquals extends Matcher {
......
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