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

Make time picker accessible (#13152)

* make time picker accessible

* use new CustomPaint a11y API

* flutter_localizations tests; use bigger distance delta

* fix am/pm control; selected values

* fix translations; remove @mustCallSuper in describeSemanticsConfiguration

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