support increase and decrease SemanticsActions in time picker (#13689) * support increase and decrease SemanticsActions in time picker * test roll over
diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index 17aeed6..7db5cef 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart
@@ -70,6 +70,7 @@ @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 @@ 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 @@ 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 @@ @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 @@ 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 @@ @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 @@ onTimeChange: onChanged, onModeChange: _handleChangeMode, targetPlatform: themeData.platform, + use24HourDials: use24HourDials, ); final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext); @@ -881,7 +947,7 @@ ), properties: new SemanticsProperties( selected: label.value == selectedValue, - label: labelPainter.text.text, + value: labelPainter.text.text, textDirection: textDirection, onTap: label.onTap, ), @@ -964,11 +1030,11 @@ @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 @@ _TimePickerMode _mode = _TimePickerMode.hour; _TimePickerMode _lastModeAnnounced; + + TimeOfDay get selectedTime => _selectedTime; TimeOfDay _selectedTime; + Timer _vibrateTimer; MaterialLocalizations localizations; @@ -1453,6 +1522,7 @@ 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 @@ aspectRatio: 1.0, child: new _Dial( mode: _mode, - use24HourDials: hourFormat(of: timeOfDayFormat) != HourFormat.h, + use24HourDials: use24HourDials, selectedTime: _selectedTime, onChanged: _handleTimeChanged, ) @@ -1491,6 +1561,7 @@ orientation: orientation, onModeChanged: _handleModeChanged, onChanged: _handleTimeChanged, + use24HourDials: use24HourDials, ); assert(orientation != null);
diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index 35c3264..a4c3fd5 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart
@@ -16,6 +16,10 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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);
diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index abe732e..3faa708 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart
@@ -335,12 +335,20 @@ @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 @@ 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; }