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 {