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;
   }