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
diff --git a/packages/flutter/lib/src/material/material_localizations.dart b/packages/flutter/lib/src/material/material_localizations.dart
index cc48fde..e20746b 100644
--- a/packages/flutter/lib/src/material/material_localizations.dart
+++ b/packages/flutter/lib/src/material/material_localizations.dart
@@ -119,6 +119,14 @@
   /// 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
@@ -506,6 +514,12 @@
   String get postMeridiemAbbreviation => 'PM';
 
   @override
+  String get timePickerHourModeAnnouncement => 'Select hours';
+
+  @override
+  String get timePickerMinuteModeAnnouncement => 'Select minutes';
+
+  @override
   TimeOfDayFormat timeOfDayFormat({ bool alwaysUse24HourFormat: false }) {
     return alwaysUse24HourFormat
       ? TimeOfDayFormat.HH_colon_mm
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index cbcce41..a617b03 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -147,7 +147,7 @@
   ///
   /// 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.
diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart
index ef1f80c..17aeed6 100644
--- a/packages/flutter/lib/src/material/time_picker.dart
+++ b/packages/flutter/lib/src/material/time_picker.dart
@@ -6,6 +6,7 @@
 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 @@
     @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 @@
        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 @@
   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 @@
 
   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 @@
     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(
-        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),
-        ],
-      ),
+    return new Column(
+      mainAxisSize: MainAxisSize.min,
+      children: <Widget>[
+        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 @@
     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 @@
 
   @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 @@
 
     return new GestureDetector(
       onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
-      child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle),
+      child: new Semantics(
+        selected: fragmentContext.mode == _TimePickerMode.minute,
+        hint: localizations.timePickerMinuteModeAnnouncement,
+        child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle),
+      ),
     );
   }
 }
@@ -636,6 +694,7 @@
       inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor),
       onTimeChange: onChanged,
       onModeChange: _handleChangeMode,
+      targetPlatform: themeData.platform,
     );
 
     final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext);
@@ -661,26 +720,28 @@
   }
 }
 
-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 @@
     @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 @@
                                  -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 @@
     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 @@
   @override
   void initState() {
     super.initState();
+    _updateDialRingFromWidget();
     _thetaController = new AnimationController(
       duration: _kDialAnimateDuration,
       vsync: this,
@@ -827,8 +968,14 @@
       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 @@
   }
 
   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 @@
     }
   }
 
-  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 @@
     _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 @@
     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 @@
       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 @@
     }
 
     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 @@
     }
 
     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 @@
           accentColor: themeData.accentColor,
           theta: _theta.value,
           activeRing: _activeRing,
-        )
+          textDirection: Directionality.of(context),
+        ),
       )
     );
   }
@@ -1105,9 +1364,19 @@
     _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 @@
     _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 @@
   @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 @@
 }) 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));
+}
diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart
index ed942ef..df7082e 100644
--- a/packages/flutter/lib/src/rendering/custom_paint.dart
+++ b/packages/flutter/lib/src/rendering/custom_paint.dart
@@ -432,7 +432,8 @@
     // Check if we need to rebuild semantics.
     if (newPainter == null) {
       assert(oldPainter != null); // We should be called only for changes.
-      markNeedsSemanticsUpdate();
+      if (attached)
+        markNeedsSemanticsUpdate();
     } else if (oldPainter == null ||
         newPainter.runtimeType != oldPainter.runtimeType ||
         newPainter.shouldRebuildSemantics(oldPainter)) {
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index 07fabe8..d421e6c 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -822,7 +822,8 @@
   /// 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 @@
 
   void _didDisposeSemanticsHandle() {
     assert(_semanticsOwner != null);
-    if (--_outstandingSemanticsHandle == 0) {
+    _outstandingSemanticsHandle -= 1;
+    if (_outstandingSemanticsHandle == 0) {
       _semanticsOwner.dispose();
       _semanticsOwner = null;
       if (onSemanticsOwnerDisposed != null)
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart
index 064ca8e..13ca34e 100644
--- a/packages/flutter/lib/src/rendering/proxy_box.dart
+++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -2583,8 +2583,8 @@
   /// 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 @@
 
   @override
   void describeSemanticsConfiguration(SemanticsConfiguration config) {
+    super.describeSemanticsConfiguration(config);
     config.isSemanticBoundary = container;
     config.explicitChildNodes = explicitChildNodes;
 
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart
index 6f3e37a..96256ea 100644
--- a/packages/flutter/lib/src/semantics/semantics.dart
+++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -325,7 +325,7 @@
   /// 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 @@
   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 @@
   /// 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 @@
   /// 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.
   ///
diff --git a/packages/flutter/lib/src/semantics/semantics_event.dart b/packages/flutter/lib/src/semantics/semantics_event.dart
index 24cfbff..4310f61 100644
--- a/packages/flutter/lib/src/semantics/semantics_event.dart
+++ b/packages/flutter/lib/src/semantics/semantics_event.dart
@@ -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.
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index 258b415..bde92fe 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -4922,7 +4922,7 @@
 /// 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.
diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart
index 7e7ad2e..35c3264 100644
--- a/packages/flutter/test/material/time_picker_test.dart
+++ b/packages/flutter/test/material/time_picker_test.dart
@@ -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/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 @@
   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 @@
 }
 
 void main() {
+  group('Time picker', () {
+    _tests();
+  });
+}
+
+void _tests() {
   testWidgets('tap-select an hour', (WidgetTester tester) async {
     TimeOfDay result;
 
@@ -210,7 +220,8 @@
   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 @@
         ],
         child: new MediaQuery(
           data: new MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat),
-          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();
-                });
-              },
+          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: 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);
+  }
 }
diff --git a/packages/flutter/test/rendering/mock_canvas.dart b/packages/flutter/test/rendering/mock_canvas.dart
index 654e19d..f6dfb78 100644
--- a/packages/flutter/test/rendering/mock_canvas.dart
+++ b/packages/flutter/test/rendering/mock_canvas.dart
@@ -282,8 +282,15 @@
   /// 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 @@
   }
 
   @override
-  void paragraph({ ui.Paragraph paragraph, Offset offset }) {
+  void paragraph({ ui.Paragraph paragraph, dynamic offset }) {
     _predicates.add(new _FunctionPaintPredicate(#drawParagraph, <dynamic>[paragraph, offset]));
   }
 
@@ -1140,8 +1147,12 @@
     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();
   }
diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart
index 99c0702..53ffe05 100644
--- a/packages/flutter/test/widgets/semantics_tester.dart
+++ b/packages/flutter/test/widgets/semantics_tester.dart
@@ -299,6 +299,47 @@
 
   @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 @@
 
   @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
diff --git a/packages/flutter_localizations/lib/src/l10n/localizations.dart b/packages/flutter_localizations/lib/src/l10n/localizations.dart
index 9e30417..9f82310 100644
--- a/packages/flutter_localizations/lib/src/l10n/localizations.dart
+++ b/packages/flutter_localizations/lib/src/l10n/localizations.dart
@@ -45,6 +45,8 @@
     'viewLicensesButtonLabel': r'الاطّلاع على التراخيص',
     'anteMeridiemAbbreviation': r'ص',
     'postMeridiemAbbreviation': r'م',
+    'timePickerHourModeAnnouncement': r'حدد ساعات',
+    'timePickerMinuteModeAnnouncement': r'حدد دقائق',
   },
   'de': const <String, String>{
     'scriptCategory': r'English-like',
@@ -77,6 +79,8 @@
     '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 @@
     '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 @@
     '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 @@
     'viewLicensesButtonLabel': r'مشاهده مجوزها',
     'anteMeridiemAbbreviation': r'ق.ظ.',
     'postMeridiemAbbreviation': r'ب.ظ.',
+    'timePickerHourModeAnnouncement': r'ساعت ها را انتخاب کنید',
+    'timePickerMinuteModeAnnouncement': r'دقیقه را انتخاب کنید',
   },
   'fr': const <String, String>{
     'scriptCategory': r'English-like',
@@ -458,6 +468,8 @@
     '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 @@
     'viewLicensesButtonLabel': r'הצגת הרישיונות',
     'anteMeridiemAbbreviation': r'AM',
     'postMeridiemAbbreviation': r'PM',
+    'timePickerHourModeAnnouncement': r'בחר שעות',
+    'timePickerMinuteModeAnnouncement': r'בחר דקות',
   },
   'it': const <String, String>{
     'scriptCategory': r'English-like',
@@ -557,6 +571,8 @@
     '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 @@
     'viewLicensesButtonLabel': r'ライセンスを表示',
     'anteMeridiemAbbreviation': r'AM',
     'postMeridiemAbbreviation': r'PM',
+    'timePickerHourModeAnnouncement': r'時を選択',
+    'timePickerMinuteModeAnnouncement': r'分を選択',
   },
   'ps': const <String, String>{
     'scriptCategory': r'tall',
@@ -616,6 +634,8 @@
     'pasteButtonLabel': r'پیټ کړئ',
     'selectAllButtonLabel': r'غوره کړئ',
     'viewLicensesButtonLabel': r'لیدلس وګورئ',
+    'timePickerHourModeAnnouncement': r'وختونه وټاکئ',
+    'timePickerMinuteModeAnnouncement': r'منې غوره کړئ',
   },
   'pt': const <String, String>{
     'scriptCategory': r'English-like',
@@ -644,6 +664,8 @@
     '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 @@
     'viewLicensesButtonLabel': r'ЛИЦЕНЗИИ',
     'anteMeridiemAbbreviation': r'АМ',
     'postMeridiemAbbreviation': r'PM',
+    'timePickerHourModeAnnouncement': r'ВЫБРАТЬ ЧАСЫ',
+    'timePickerMinuteModeAnnouncement': r'ВЫБРАТЬ МИНУТЫ',
   },
   'ur': const <String, String>{
     'scriptCategory': r'tall',
@@ -740,6 +764,8 @@
     'viewLicensesButtonLabel': r'لائسنسز دیکھیں',
     'anteMeridiemAbbreviation': r'AM',
     'postMeridiemAbbreviation': r'PM',
+    'timePickerHourModeAnnouncement': r'گھنٹے منتخب کریں',
+    'timePickerMinuteModeAnnouncement': r'منٹ منتخب کریں',
   },
   'zh': const <String, String>{
     'scriptCategory': r'dense',
@@ -771,6 +797,8 @@
     'previousMonthTooltip': r'上个月',
     'anteMeridiemAbbreviation': r'上午',
     'postMeridiemAbbreviation': r'下午',
+    'timePickerHourModeAnnouncement': r'选择小时',
+    'timePickerMinuteModeAnnouncement': r'选择分钟',
   },
 };
 
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ar.arb b/packages/flutter_localizations/lib/src/l10n/material_ar.arb
index 4e284c1..069a418 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_ar.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_ar.arb
@@ -31,5 +31,7 @@
   "selectAllButtonLabel": "اختيار الكل",
   "viewLicensesButtonLabel": "الاطّلاع على التراخيص",
   "anteMeridiemAbbreviation": "ص",
-  "postMeridiemAbbreviation": "م"
+  "postMeridiemAbbreviation": "م",
+  "timePickerHourModeAnnouncement": "حدد ساعات",
+  "timePickerMinuteModeAnnouncement": "حدد دقائق"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_de.arb b/packages/flutter_localizations/lib/src/l10n/material_de.arb
index 5d248d2..4fc5d5f 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_de.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_de.arb
@@ -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"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_en.arb b/packages/flutter_localizations/lib/src/l10n/material_en.arb
index 215f2bd..27cc940 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_en.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_en.arb
@@ -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."
   }
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_es.arb b/packages/flutter_localizations/lib/src/l10n/material_es.arb
index aa5e6d6..cf9dc85 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_es.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_es.arb
@@ -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"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_fa.arb b/packages/flutter_localizations/lib/src/l10n/material_fa.arb
index 12d07b2..e7cbf93 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_fa.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_fa.arb
@@ -27,5 +27,7 @@
   "selectAllButtonLabel": "انتخاب همه",
   "viewLicensesButtonLabel": "مشاهده مجوزها",
   "anteMeridiemAbbreviation": "ق.ظ.",
-  "postMeridiemAbbreviation": "ب.ظ."
+  "postMeridiemAbbreviation": "ب.ظ.",
+  "timePickerHourModeAnnouncement": "ساعت ها را انتخاب کنید",
+  "timePickerMinuteModeAnnouncement": "دقیقه را انتخاب کنید"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_fr.arb b/packages/flutter_localizations/lib/src/l10n/material_fr.arb
index 69d7b29..9eb4fd2 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_fr.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_fr.arb
@@ -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"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_he.arb b/packages/flutter_localizations/lib/src/l10n/material_he.arb
index 39083de..b6fe9e9 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_he.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_he.arb
@@ -29,5 +29,7 @@
   "selectAllButtonLabel": "בחירת הכול",
   "viewLicensesButtonLabel": "הצגת הרישיונות",
   "anteMeridiemAbbreviation": "AM",
-  "postMeridiemAbbreviation": "PM"
+  "postMeridiemAbbreviation": "PM",
+  "timePickerHourModeAnnouncement": "בחר שעות",
+  "timePickerMinuteModeAnnouncement": "בחר דקות"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_it.arb b/packages/flutter_localizations/lib/src/l10n/material_it.arb
index a0faf2a..785e32a 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_it.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_it.arb
@@ -27,5 +27,7 @@
   "selectAllButtonLabel": "SELEZIONA TUTTO",
   "viewLicensesButtonLabel": "VISUALIZZA LICENZE",
   "anteMeridiemAbbreviation": "AM",
-  "postMeridiemAbbreviation": "PM"
+  "postMeridiemAbbreviation": "PM",
+  "timePickerHourModeAnnouncement": "Seleziona ore",
+  "timePickerMinuteModeAnnouncement": "Seleziona minuti"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ja.arb b/packages/flutter_localizations/lib/src/l10n/material_ja.arb
index c34a6ab..3bedf74 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_ja.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_ja.arb
@@ -27,5 +27,7 @@
   "selectAllButtonLabel": "すべて選択",
   "viewLicensesButtonLabel": "ライセンスを表示",
   "anteMeridiemAbbreviation": "AM",
-  "postMeridiemAbbreviation": "PM"
+  "postMeridiemAbbreviation": "PM",
+  "timePickerHourModeAnnouncement": "時を選択",
+  "timePickerMinuteModeAnnouncement": "分を選択"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ps.arb b/packages/flutter_localizations/lib/src/l10n/material_ps.arb
index 89a41f6..c8d3769 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_ps.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_ps.arb
@@ -26,5 +26,7 @@
   "okButtonLabel": "سمه ده",
   "pasteButtonLabel": "پیټ کړئ",
   "selectAllButtonLabel": "غوره کړئ",
-  "viewLicensesButtonLabel": "لیدلس وګورئ"
+  "viewLicensesButtonLabel": "لیدلس وګورئ",
+  "timePickerHourModeAnnouncement": "وختونه وټاکئ",
+  "timePickerMinuteModeAnnouncement": "منې غوره کړئ"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_pt.arb b/packages/flutter_localizations/lib/src/l10n/material_pt.arb
index e2b95c4..c4dd6d2 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_pt.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_pt.arb
@@ -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"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ru.arb b/packages/flutter_localizations/lib/src/l10n/material_ru.arb
index 3b36b5f..d4c0e0b 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_ru.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_ru.arb
@@ -30,5 +30,7 @@
   "selectAllButtonLabel": "ВЫБРАТЬ ВСЕ",
   "viewLicensesButtonLabel": "ЛИЦЕНЗИИ",
   "anteMeridiemAbbreviation": "АМ",
-  "postMeridiemAbbreviation": "PM"
+  "postMeridiemAbbreviation": "PM",
+  "timePickerHourModeAnnouncement": "ВЫБРАТЬ ЧАСЫ",
+  "timePickerMinuteModeAnnouncement": "ВЫБРАТЬ МИНУТЫ"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_ur.arb b/packages/flutter_localizations/lib/src/l10n/material_ur.arb
index 1276c28..8d41993 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_ur.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_ur.arb
@@ -27,5 +27,7 @@
   "selectAllButtonLabel": "سبھی منتخب کریں",
   "viewLicensesButtonLabel": "لائسنسز دیکھیں",
   "anteMeridiemAbbreviation": "AM",
-  "postMeridiemAbbreviation": "PM"
+  "postMeridiemAbbreviation": "PM",
+  "timePickerHourModeAnnouncement": "گھنٹے منتخب کریں",
+  "timePickerMinuteModeAnnouncement": "منٹ منتخب کریں"
 }
diff --git a/packages/flutter_localizations/lib/src/l10n/material_zh.arb b/packages/flutter_localizations/lib/src/l10n/material_zh.arb
index 8afe969..791a974 100644
--- a/packages/flutter_localizations/lib/src/l10n/material_zh.arb
+++ b/packages/flutter_localizations/lib/src/l10n/material_zh.arb
@@ -27,5 +27,7 @@
   "nextMonthTooltip": "下个月",
   "previousMonthTooltip": "上个月",
   "anteMeridiemAbbreviation": "上午",
-  "postMeridiemAbbreviation": "下午"
+  "postMeridiemAbbreviation": "下午",
+  "timePickerHourModeAnnouncement": "选择小时",
+  "timePickerMinuteModeAnnouncement": "选择分钟"
 }
diff --git a/packages/flutter_localizations/lib/src/material_localizations.dart b/packages/flutter_localizations/lib/src/material_localizations.dart
index a1538f6..800ddfa 100644
--- a/packages/flutter_localizations/lib/src/material_localizations.dart
+++ b/packages/flutter_localizations/lib/src/material_localizations.dart
@@ -316,6 +316,12 @@
   @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:
   ///
diff --git a/packages/flutter_localizations/test/time_picker_test.dart b/packages/flutter_localizations/test/time_picker_test.dart
index 8b70e49..f3c2ada 100644
--- a/packages/flutter_localizations/test/time_picker_test.dart
+++ b/packages/flutter_localizations/test/time_picker_test.dart
@@ -140,57 +140,58 @@
         ],
         child: new MediaQuery(
           data: new MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat),
-          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();
-                });
-              },
+          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));
+                      },
+                      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);
   });
 }
diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart
index f5af019..7279cbe 100644
--- a/packages/flutter_test/lib/src/matchers.dart
+++ b/packages/flutter_test/lib/src/matchers.dart
@@ -597,6 +597,7 @@
   Offset: _offsetDistance,
   int: _intDistance,
   double: _doubleDistance,
+  Rect: _rectDistance,
 };
 
 int _intDistance(int a, int b) => (b - a).abs();
@@ -610,6 +611,13 @@
   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 @@
         '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 {