Unverified Commit 81d48342 authored by Yegor's avatar Yegor Committed by GitHub

support increase and decrease SemanticsActions in time picker (#13689)

* support increase and decrease SemanticsActions in time picker

* test roll over
parent 7766b2a1
......@@ -70,6 +70,7 @@ class _TimePickerFragmentContext {
@required this.onTimeChange,
@required this.onModeChange,
@required this.targetPlatform,
@required this.use24HourDials,
}) : assert(headerTextTheme != null),
assert(textDirection != null),
assert(selectedTime != null),
......@@ -80,7 +81,8 @@ class _TimePickerFragmentContext {
assert(inactiveStyle != null),
assert(onTimeChange != null),
assert(onModeChange != null),
assert(targetPlatform != null);
assert(targetPlatform != null),
assert(use24HourDials != null);
final TextTheme headerTextTheme;
final TextDirection textDirection;
......@@ -93,6 +95,7 @@ class _TimePickerFragmentContext {
final ValueChanged<TimeOfDay> onTimeChange;
final ValueChanged<_TimePickerMode> onModeChange;
final TargetPlatform targetPlatform;
final bool use24HourDials;
}
/// Contains the [widget] and layout properties of an atom of time information,
......@@ -278,21 +281,59 @@ class _HourControl extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour
? fragmentContext.activeStyle
: fragmentContext.inactiveStyle;
final String formattedHour = localizations.formatHour(
fragmentContext.selectedTime,
alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
alwaysUse24HourFormat: alwaysUse24HourFormat,
);
TimeOfDay hoursFromSelected(int hoursToAdd) {
if (fragmentContext.use24HourDials) {
final int selectedHour = fragmentContext.selectedTime.hour;
return fragmentContext.selectedTime.replacing(
hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay,
);
} else {
// Cycle 1 through 12 without changing day period.
final int periodOffset = fragmentContext.selectedTime.periodOffset;
final int hours = fragmentContext.selectedTime.hourOfPeriod;
return fragmentContext.selectedTime.replacing(
hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod,
);
}
}
final TimeOfDay nextHour = hoursFromSelected(1);
final String formattedNextHour = localizations.formatHour(
nextHour,
alwaysUse24HourFormat: alwaysUse24HourFormat,
);
final TimeOfDay previousHour = hoursFromSelected(-1);
final String formattedPreviousHour = localizations.formatHour(
previousHour,
alwaysUse24HourFormat: alwaysUse24HourFormat,
);
return new GestureDetector(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
child: new Semantics(
selected: fragmentContext.mode == _TimePickerMode.hour,
hint: localizations.timePickerHourModeAnnouncement,
child: new Text(formattedHour, style: hourStyle),
value: formattedHour,
increasedValue: formattedNextHour,
onIncrease: () {
fragmentContext.onTimeChange(nextHour);
},
decreasedValue: formattedPreviousHour,
onDecrease: () {
fragmentContext.onTimeChange(previousHour);
},
child: new ExcludeSemantics(
child: new Text(formattedHour, style: hourStyle),
),
),
);
}
......@@ -332,13 +373,34 @@ class _MinuteControl extends StatelessWidget {
final TextStyle minuteStyle = fragmentContext.mode == _TimePickerMode.minute
? fragmentContext.activeStyle
: fragmentContext.inactiveStyle;
final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime);
final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing(
minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour,
);
final String formattedNextMinute = localizations.formatMinute(nextMinute);
final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing(
minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour,
);
final String formattedPreviousMinute = localizations.formatMinute(previousMinute);
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),
value: formattedMinute,
increasedValue: formattedNextMinute,
onIncrease: () {
fragmentContext.onTimeChange(nextMinute);
},
decreasedValue: formattedPreviousMinute,
onDecrease: () {
fragmentContext.onTimeChange(previousMinute);
},
child: new ExcludeSemantics(
child: new Text(formattedMinute, style: minuteStyle),
),
),
);
}
......@@ -605,15 +667,18 @@ class _TimePickerHeader extends StatelessWidget {
@required this.orientation,
@required this.onModeChanged,
@required this.onChanged,
@required this.use24HourDials,
}) : assert(selectedTime != null),
assert(mode != null),
assert(orientation != null);
assert(orientation != null),
assert(use24HourDials != null);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final Orientation orientation;
final ValueChanged<_TimePickerMode> onModeChanged;
final ValueChanged<TimeOfDay> onChanged;
final bool use24HourDials;
void _handleChangeMode(_TimePickerMode value) {
if (value != mode)
......@@ -695,6 +760,7 @@ class _TimePickerHeader extends StatelessWidget {
onTimeChange: onChanged,
onModeChange: _handleChangeMode,
targetPlatform: themeData.platform,
use24HourDials: use24HourDials,
);
final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext);
......@@ -881,7 +947,7 @@ class _DialPainter extends CustomPainter {
),
properties: new SemanticsProperties(
selected: label.value == selectedValue,
label: labelPainter.text.text,
value: labelPainter.text.text,
textDirection: textDirection,
onTap: label.onTap,
),
......@@ -964,11 +1030,11 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override
void didUpdateWidget(_Dial oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.mode != oldWidget.mode) {
_updateDialRingFromWidget();
if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) {
if (!_dragging)
_animateTo(_getThetaForTime(widget.selectedTime));
}
_updateDialRingFromWidget();
}
void _updateDialRingFromWidget() {
......@@ -1374,7 +1440,10 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
_TimePickerMode _mode = _TimePickerMode.hour;
_TimePickerMode _lastModeAnnounced;
TimeOfDay get selectedTime => _selectedTime;
TimeOfDay _selectedTime;
Timer _vibrateTimer;
MaterialLocalizations localizations;
......@@ -1453,6 +1522,7 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData media = MediaQuery.of(context);
final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
final Widget picker = new Padding(
padding: const EdgeInsets.all(16.0),
......@@ -1460,7 +1530,7 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
aspectRatio: 1.0,
child: new _Dial(
mode: _mode,
use24HourDials: hourFormat(of: timeOfDayFormat) != HourFormat.h,
use24HourDials: use24HourDials,
selectedTime: _selectedTime,
onChanged: _handleTimeChanged,
)
......@@ -1491,6 +1561,7 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
orientation: orientation,
onModeChanged: _handleModeChanged,
onChanged: _handleTimeChanged,
use24HourDials: use24HourDials,
);
assert(orientation != null);
......
......@@ -16,6 +16,10 @@ import '../rendering/recording_canvas.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
final Finder _hourControl = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourControl');
final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_MinuteControl');
final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_TimePickerDialog');
class _TimePickerLauncher extends StatelessWidget {
const _TimePickerLauncher({ Key key, this.onChanged, this.locale }) : super(key: key);
......@@ -301,9 +305,9 @@ void _tests() {
await mediaQueryBoilerplate(tester, true);
expect(semantics, isNot(includesNodeWith(label: ':')));
expect(semantics.nodesWith(label: '00'), hasLength(2),
expect(semantics.nodesWith(value: '00'), hasLength(2),
reason: '00 appears once in the header, then again in the dial');
expect(semantics.nodesWith(label: '07'), hasLength(2),
expect(semantics.nodesWith(value: '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'));
......@@ -355,7 +359,7 @@ void _tests() {
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.tap(_minuteControl);
await tester.pumpAndSettle();
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
......@@ -390,6 +394,114 @@ void _tests() {
dialPaint = tester.widget(findDialPaint);
expect('${dialPaint.painter.activeRing}', '_DialRing.outer');
});
testWidgets('can increment and decrement hours', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
Future<Null> actAndExpect({ String initialValue, SemanticsAction action, String finalValue }) async {
final SemanticsNode elevenHours = semantics.nodesWith(
value: initialValue,
ancestor: tester.renderObject(_hourControl).debugSemantics,
).single;
tester.binding.pipelineOwner.semanticsOwner.performAction(elevenHours.id, action);
await tester.pumpAndSettle();
expect(
find.descendant(of: _hourControl, matching: find.text(finalValue)),
findsOneWidget,
);
}
// 12-hour format
await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 0));
await actAndExpect(
initialValue: '11',
action: SemanticsAction.increase,
finalValue: '12',
);
await actAndExpect(
initialValue: '12',
action: SemanticsAction.increase,
finalValue: '1',
);
// Ensure we preserve day period as we roll over.
final dynamic pickerState = tester.state(_timePickerDialog);
expect(pickerState.selectedTime, const TimeOfDay(hour: 1, minute: 0));
await actAndExpect(
initialValue: '1',
action: SemanticsAction.decrease,
finalValue: '12',
);
await tester.pumpWidget(new Container()); // clear old boilerplate
// 24-hour format
await mediaQueryBoilerplate(tester, true, initialTime: const TimeOfDay(hour: 23, minute: 0));
await actAndExpect(
initialValue: '23',
action: SemanticsAction.increase,
finalValue: '00',
);
await actAndExpect(
initialValue: '00',
action: SemanticsAction.increase,
finalValue: '01',
);
await actAndExpect(
initialValue: '01',
action: SemanticsAction.decrease,
finalValue: '00',
);
await actAndExpect(
initialValue: '00',
action: SemanticsAction.decrease,
finalValue: '23',
);
});
testWidgets('can increment and decrement minutes', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
Future<Null> actAndExpect({ String initialValue, SemanticsAction action, String finalValue }) async {
final SemanticsNode elevenHours = semantics.nodesWith(
value: initialValue,
ancestor: tester.renderObject(_minuteControl).debugSemantics,
).single;
tester.binding.pipelineOwner.semanticsOwner.performAction(elevenHours.id, action);
await tester.pumpAndSettle();
expect(
find.descendant(of: _minuteControl, matching: find.text(finalValue)),
findsOneWidget,
);
}
await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 58));
await actAndExpect(
initialValue: '58',
action: SemanticsAction.increase,
finalValue: '59',
);
await actAndExpect(
initialValue: '59',
action: SemanticsAction.increase,
finalValue: '00',
);
// Ensure we preserve hour period as we roll over.
final dynamic pickerState = tester.state(_timePickerDialog);
expect(pickerState.selectedTime, const TimeOfDay(hour: 11, minute: 0));
await actAndExpect(
initialValue: '00',
action: SemanticsAction.decrease,
finalValue: '59',
);
await actAndExpect(
initialValue: '59',
action: SemanticsAction.decrease,
finalValue: '58',
);
});
}
final Finder findDialPaint = find.descendant(
......@@ -436,9 +548,9 @@ class _CustomPainterSemanticsTester {
int i = 0;
for (_SemanticsNodeExpectation expectation in expectedNodes) {
expect(semantics, includesNodeWith(label: expectation.label));
expect(semantics, includesNodeWith(value: expectation.label));
final Iterable<SemanticsNode> dialLabelNodes = semantics
.nodesWith(label: expectation.label)
.nodesWith(value: 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);
......
......@@ -335,12 +335,20 @@ class SemanticsTester {
@override
String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode}';
/// Returns all semantics nodes in the current semantics tree whose properties
/// match the non-null arguments.
///
/// If multiple arguments are non-null, each of the returned nodes must match
/// on all of them.
///
/// If `ancestor` is not null, only the descendants of it are returned.
Iterable<SemanticsNode> nodesWith({
String label,
String value,
TextDirection textDirection,
List<SemanticsAction> actions,
List<SemanticsFlags> flags,
SemanticsNode ancestor,
}) {
bool checkNode(SemanticsNode node) {
if (label != null && node.label != label)
......@@ -372,7 +380,11 @@ class SemanticsTester {
node.visitChildren(visit);
return true;
}
visit(tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode);
if (ancestor != null) {
visit(ancestor);
} else {
visit(tester.binding.pipelineOwner.semanticsOwner.rootSemanticsNode);
}
return result;
}
......
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