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