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
diff --git a/packages/flutter/lib/src/material/material_localizations.dart b/packages/flutter/lib/src/material/material_localizations.dart index cc48fde..e20746b 100644 --- a/packages/flutter/lib/src/material/material_localizations.dart +++ b/packages/flutter/lib/src/material/material_localizations.dart
@@ -119,6 +119,14 @@ /// 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 @@ -506,6 +514,12 @@ String get postMeridiemAbbreviation => 'PM'; @override + String get timePickerHourModeAnnouncement => 'Select hours'; + + @override + String get timePickerMinuteModeAnnouncement => 'Select minutes'; + + @override TimeOfDayFormat timeOfDayFormat({ bool alwaysUse24HourFormat: false }) { return alwaysUse24HourFormat ? TimeOfDayFormat.HH_colon_mm
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index cbcce41..a617b03 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart
@@ -147,7 +147,7 @@ /// /// 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.
diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index ef1f80c..17aeed6 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart
@@ -6,6 +6,7 @@ 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 @@ @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 @@ 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 @@ 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 @@ 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 @@ 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( - mainAxisSize: MainAxisSize.min, - children: <Widget>[ - new Text(materialLocalizations.anteMeridiemAbbreviation, style: amStyle), - const SizedBox(width: 0.0, height: 4.0), // Vertical spacer - new Text(materialLocalizations.postMeridiemAbbreviation, style: pmStyle), - ], - ), + return new Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + 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 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 @@ 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 @@ @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 @@ return new GestureDetector( onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context), - child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle), + child: new Semantics( + selected: fragmentContext.mode == _TimePickerMode.minute, + hint: localizations.timePickerMinuteModeAnnouncement, + child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle), + ), ); } } @@ -636,6 +694,7 @@ inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor), onTimeChange: onChanged, onModeChange: _handleChangeMode, + targetPlatform: themeData.platform, ); final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext); @@ -661,26 +720,28 @@ } } -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 @@ @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 @@ -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 @@ 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 @@ @override void initState() { super.initState(); + _updateDialRingFromWidget(); _thetaController = new AnimationController( duration: _kDialAnimateDuration, vsync: this, @@ -827,8 +968,14 @@ 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 @@ } 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 @@ } } - 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 @@ _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 @@ 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<TextPainter> _buildMinutes(TextTheme textTheme) { + 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<_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 @@ 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 @@ } 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 @@ } 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 @@ accentColor: themeData.accentColor, theta: _theta.value, activeRing: _activeRing, - ) + textDirection: Directionality.of(context), + ), ) ); } @@ -1105,9 +1364,19 @@ _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 @@ _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 @@ @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 @@ }) 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)); +}
diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index ed942ef..df7082e 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart
@@ -432,7 +432,8 @@ // Check if we need to rebuild semantics. if (newPainter == null) { assert(oldPainter != null); // We should be called only for changes. - markNeedsSemanticsUpdate(); + if (attached) + markNeedsSemanticsUpdate(); } else if (oldPainter == null || newPainter.runtimeType != oldPainter.runtimeType || newPainter.shouldRebuildSemantics(oldPainter)) {
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 07fabe8..d421e6c 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart
@@ -822,7 +822,8 @@ /// 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 @@ void _didDisposeSemanticsHandle() { assert(_semanticsOwner != null); - if (--_outstandingSemanticsHandle == 0) { + _outstandingSemanticsHandle -= 1; + if (_outstandingSemanticsHandle == 0) { _semanticsOwner.dispose(); _semanticsOwner = null; if (onSemanticsOwnerDisposed != null)
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 064ca8e..13ca34e 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -2583,8 +2583,8 @@ /// 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 @@ @override void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); config.isSemanticBoundary = container; config.explicitChildNodes = explicitChildNodes;
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 6f3e37a..96256ea 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -325,7 +325,7 @@ /// 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 @@ 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 @@ /// 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 @@ /// 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. ///
diff --git a/packages/flutter/lib/src/semantics/semantics_event.dart b/packages/flutter/lib/src/semantics/semantics_event.dart index 24cfbff..4310f61 100644 --- a/packages/flutter/lib/src/semantics/semantics_event.dart +++ b/packages/flutter/lib/src/semantics/semantics_event.dart
@@ -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.
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 258b415..bde92fe 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -4922,7 +4922,7 @@ /// 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.
diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index 7e7ad2e..35c3264 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart
@@ -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/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 @@ 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 @@ } void main() { + group('Time picker', () { + _tests(); + }); +} + +void _tests() { testWidgets('tap-select an hour', (WidgetTester tester) async { TimeOfDay result; @@ -210,7 +220,8 @@ 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 @@ ], child: new MediaQuery( data: new MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat), - 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(); - }); - }, + 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: 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<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<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> 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); + } }
diff --git a/packages/flutter/test/rendering/mock_canvas.dart b/packages/flutter/test/rendering/mock_canvas.dart index 654e19d..f6dfb78 100644 --- a/packages/flutter/test/rendering/mock_canvas.dart +++ b/packages/flutter/test/rendering/mock_canvas.dart
@@ -282,8 +282,15 @@ /// 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 @@ } @override - void paragraph({ ui.Paragraph paragraph, Offset offset }) { + void paragraph({ ui.Paragraph paragraph, dynamic offset }) { _predicates.add(new _FunctionPaintPredicate(#drawParagraph, <dynamic>[paragraph, offset])); } @@ -1140,8 +1147,12 @@ 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(); }
diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 99c0702..53ffe05 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart
@@ -299,6 +299,47 @@ @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 @@ @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
diff --git a/packages/flutter_localizations/lib/src/l10n/localizations.dart b/packages/flutter_localizations/lib/src/l10n/localizations.dart index 9e30417..9f82310 100644 --- a/packages/flutter_localizations/lib/src/l10n/localizations.dart +++ b/packages/flutter_localizations/lib/src/l10n/localizations.dart
@@ -45,6 +45,8 @@ 'viewLicensesButtonLabel': r'الاطّلاع على التراخيص', 'anteMeridiemAbbreviation': r'ص', 'postMeridiemAbbreviation': r'م', + 'timePickerHourModeAnnouncement': r'حدد ساعات', + 'timePickerMinuteModeAnnouncement': r'حدد دقائق', }, 'de': const <String, String>{ 'scriptCategory': r'English-like', @@ -77,6 +79,8 @@ '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 @@ '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 @@ '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 @@ 'viewLicensesButtonLabel': r'مشاهده مجوزها', 'anteMeridiemAbbreviation': r'ق.ظ.', 'postMeridiemAbbreviation': r'ب.ظ.', + 'timePickerHourModeAnnouncement': r'ساعت ها را انتخاب کنید', + 'timePickerMinuteModeAnnouncement': r'دقیقه را انتخاب کنید', }, 'fr': const <String, String>{ 'scriptCategory': r'English-like', @@ -458,6 +468,8 @@ '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 @@ 'viewLicensesButtonLabel': r'הצגת הרישיונות', 'anteMeridiemAbbreviation': r'AM', 'postMeridiemAbbreviation': r'PM', + 'timePickerHourModeAnnouncement': r'בחר שעות', + 'timePickerMinuteModeAnnouncement': r'בחר דקות', }, 'it': const <String, String>{ 'scriptCategory': r'English-like', @@ -557,6 +571,8 @@ '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 @@ 'viewLicensesButtonLabel': r'ライセンスを表示', 'anteMeridiemAbbreviation': r'AM', 'postMeridiemAbbreviation': r'PM', + 'timePickerHourModeAnnouncement': r'時を選択', + 'timePickerMinuteModeAnnouncement': r'分を選択', }, 'ps': const <String, String>{ 'scriptCategory': r'tall', @@ -616,6 +634,8 @@ 'pasteButtonLabel': r'پیټ کړئ', 'selectAllButtonLabel': r'غوره کړئ', 'viewLicensesButtonLabel': r'لیدلس وګورئ', + 'timePickerHourModeAnnouncement': r'وختونه وټاکئ', + 'timePickerMinuteModeAnnouncement': r'منې غوره کړئ', }, 'pt': const <String, String>{ 'scriptCategory': r'English-like', @@ -644,6 +664,8 @@ '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 @@ 'viewLicensesButtonLabel': r'ЛИЦЕНЗИИ', 'anteMeridiemAbbreviation': r'АМ', 'postMeridiemAbbreviation': r'PM', + 'timePickerHourModeAnnouncement': r'ВЫБРАТЬ ЧАСЫ', + 'timePickerMinuteModeAnnouncement': r'ВЫБРАТЬ МИНУТЫ', }, 'ur': const <String, String>{ 'scriptCategory': r'tall', @@ -740,6 +764,8 @@ 'viewLicensesButtonLabel': r'لائسنسز دیکھیں', 'anteMeridiemAbbreviation': r'AM', 'postMeridiemAbbreviation': r'PM', + 'timePickerHourModeAnnouncement': r'گھنٹے منتخب کریں', + 'timePickerMinuteModeAnnouncement': r'منٹ منتخب کریں', }, 'zh': const <String, String>{ 'scriptCategory': r'dense', @@ -771,6 +797,8 @@ 'previousMonthTooltip': r'上个月', 'anteMeridiemAbbreviation': r'上午', 'postMeridiemAbbreviation': r'下午', + 'timePickerHourModeAnnouncement': r'选择小时', + 'timePickerMinuteModeAnnouncement': r'选择分钟', }, };
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ar.arb b/packages/flutter_localizations/lib/src/l10n/material_ar.arb index 4e284c1..069a418 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_ar.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_ar.arb
@@ -31,5 +31,7 @@ "selectAllButtonLabel": "اختيار الكل", "viewLicensesButtonLabel": "الاطّلاع على التراخيص", "anteMeridiemAbbreviation": "ص", - "postMeridiemAbbreviation": "م" + "postMeridiemAbbreviation": "م", + "timePickerHourModeAnnouncement": "حدد ساعات", + "timePickerMinuteModeAnnouncement": "حدد دقائق" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_de.arb b/packages/flutter_localizations/lib/src/l10n/material_de.arb index 5d248d2..4fc5d5f 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_de.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_de.arb
@@ -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" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_en.arb b/packages/flutter_localizations/lib/src/l10n/material_en.arb index 215f2bd..27cc940 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_en.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_en.arb
@@ -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." } }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_es.arb b/packages/flutter_localizations/lib/src/l10n/material_es.arb index aa5e6d6..cf9dc85 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_es.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_es.arb
@@ -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" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_fa.arb b/packages/flutter_localizations/lib/src/l10n/material_fa.arb index 12d07b2..e7cbf93 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_fa.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_fa.arb
@@ -27,5 +27,7 @@ "selectAllButtonLabel": "انتخاب همه", "viewLicensesButtonLabel": "مشاهده مجوزها", "anteMeridiemAbbreviation": "ق.ظ.", - "postMeridiemAbbreviation": "ب.ظ." + "postMeridiemAbbreviation": "ب.ظ.", + "timePickerHourModeAnnouncement": "ساعت ها را انتخاب کنید", + "timePickerMinuteModeAnnouncement": "دقیقه را انتخاب کنید" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_fr.arb b/packages/flutter_localizations/lib/src/l10n/material_fr.arb index 69d7b29..9eb4fd2 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_fr.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_fr.arb
@@ -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" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_he.arb b/packages/flutter_localizations/lib/src/l10n/material_he.arb index 39083de..b6fe9e9 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_he.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_he.arb
@@ -29,5 +29,7 @@ "selectAllButtonLabel": "בחירת הכול", "viewLicensesButtonLabel": "הצגת הרישיונות", "anteMeridiemAbbreviation": "AM", - "postMeridiemAbbreviation": "PM" + "postMeridiemAbbreviation": "PM", + "timePickerHourModeAnnouncement": "בחר שעות", + "timePickerMinuteModeAnnouncement": "בחר דקות" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_it.arb b/packages/flutter_localizations/lib/src/l10n/material_it.arb index a0faf2a..785e32a 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_it.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_it.arb
@@ -27,5 +27,7 @@ "selectAllButtonLabel": "SELEZIONA TUTTO", "viewLicensesButtonLabel": "VISUALIZZA LICENZE", "anteMeridiemAbbreviation": "AM", - "postMeridiemAbbreviation": "PM" + "postMeridiemAbbreviation": "PM", + "timePickerHourModeAnnouncement": "Seleziona ore", + "timePickerMinuteModeAnnouncement": "Seleziona minuti" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ja.arb b/packages/flutter_localizations/lib/src/l10n/material_ja.arb index c34a6ab..3bedf74 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_ja.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_ja.arb
@@ -27,5 +27,7 @@ "selectAllButtonLabel": "すべて選択", "viewLicensesButtonLabel": "ライセンスを表示", "anteMeridiemAbbreviation": "AM", - "postMeridiemAbbreviation": "PM" + "postMeridiemAbbreviation": "PM", + "timePickerHourModeAnnouncement": "時を選択", + "timePickerMinuteModeAnnouncement": "分を選択" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ps.arb b/packages/flutter_localizations/lib/src/l10n/material_ps.arb index 89a41f6..c8d3769 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_ps.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_ps.arb
@@ -26,5 +26,7 @@ "okButtonLabel": "سمه ده", "pasteButtonLabel": "پیټ کړئ", "selectAllButtonLabel": "غوره کړئ", - "viewLicensesButtonLabel": "لیدلس وګورئ" + "viewLicensesButtonLabel": "لیدلس وګورئ", + "timePickerHourModeAnnouncement": "وختونه وټاکئ", + "timePickerMinuteModeAnnouncement": "منې غوره کړئ" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_pt.arb b/packages/flutter_localizations/lib/src/l10n/material_pt.arb index e2b95c4..c4dd6d2 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_pt.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_pt.arb
@@ -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" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ru.arb b/packages/flutter_localizations/lib/src/l10n/material_ru.arb index 3b36b5f..d4c0e0b 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_ru.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_ru.arb
@@ -30,5 +30,7 @@ "selectAllButtonLabel": "ВЫБРАТЬ ВСЕ", "viewLicensesButtonLabel": "ЛИЦЕНЗИИ", "anteMeridiemAbbreviation": "АМ", - "postMeridiemAbbreviation": "PM" + "postMeridiemAbbreviation": "PM", + "timePickerHourModeAnnouncement": "ВЫБРАТЬ ЧАСЫ", + "timePickerMinuteModeAnnouncement": "ВЫБРАТЬ МИНУТЫ" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ur.arb b/packages/flutter_localizations/lib/src/l10n/material_ur.arb index 1276c28..8d41993 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_ur.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_ur.arb
@@ -27,5 +27,7 @@ "selectAllButtonLabel": "سبھی منتخب کریں", "viewLicensesButtonLabel": "لائسنسز دیکھیں", "anteMeridiemAbbreviation": "AM", - "postMeridiemAbbreviation": "PM" + "postMeridiemAbbreviation": "PM", + "timePickerHourModeAnnouncement": "گھنٹے منتخب کریں", + "timePickerMinuteModeAnnouncement": "منٹ منتخب کریں" }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_zh.arb b/packages/flutter_localizations/lib/src/l10n/material_zh.arb index 8afe969..791a974 100644 --- a/packages/flutter_localizations/lib/src/l10n/material_zh.arb +++ b/packages/flutter_localizations/lib/src/l10n/material_zh.arb
@@ -27,5 +27,7 @@ "nextMonthTooltip": "下个月", "previousMonthTooltip": "上个月", "anteMeridiemAbbreviation": "上午", - "postMeridiemAbbreviation": "下午" + "postMeridiemAbbreviation": "下午", + "timePickerHourModeAnnouncement": "选择小时", + "timePickerMinuteModeAnnouncement": "选择分钟" }
diff --git a/packages/flutter_localizations/lib/src/material_localizations.dart b/packages/flutter_localizations/lib/src/material_localizations.dart index a1538f6..800ddfa 100644 --- a/packages/flutter_localizations/lib/src/material_localizations.dart +++ b/packages/flutter_localizations/lib/src/material_localizations.dart
@@ -316,6 +316,12 @@ @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: ///
diff --git a/packages/flutter_localizations/test/time_picker_test.dart b/packages/flutter_localizations/test/time_picker_test.dart index 8b70e49..f3c2ada 100644 --- a/packages/flutter_localizations/test/time_picker_test.dart +++ b/packages/flutter_localizations/test/time_picker_test.dart
@@ -140,57 +140,58 @@ ], child: new MediaQuery( data: new MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat), - 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(); - }); - }, + 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)); + }, + 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<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<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> 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); }); }
diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index f5af019..7279cbe 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart
@@ -597,6 +597,7 @@ Offset: _offsetDistance, int: _intDistance, double: _doubleDistance, + Rect: _rectDistance, }; int _intDistance(int a, int b) => (b - a).abs(); @@ -610,6 +611,13 @@ 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 @@ '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 {