• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2015 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:math' as math;
7
8import 'package:flutter/rendering.dart';
9import 'package:flutter/services.dart';
10import 'package:flutter/widgets.dart';
11
12import 'button_bar.dart';
13import 'button_theme.dart';
14import 'colors.dart';
15import 'debug.dart';
16import 'dialog.dart';
17import 'feedback.dart';
18import 'flat_button.dart';
19import 'ink_well.dart';
20import 'material.dart';
21import 'material_localizations.dart';
22import 'text_theme.dart';
23import 'theme.dart';
24import 'theme_data.dart';
25import 'time.dart';
26
27// Examples can assume:
28// BuildContext context;
29
30const Duration _kDialAnimateDuration = Duration(milliseconds: 200);
31const double _kTwoPi = 2 * math.pi;
32const Duration _kVibrateCommitDelay = Duration(milliseconds: 100);
33
34enum _TimePickerMode { hour, minute }
35
36const double _kTimePickerHeaderPortraitHeight = 96.0;
37const double _kTimePickerHeaderLandscapeWidth = 168.0;
38
39
40const double _kTimePickerWidthPortrait = 328.0;
41const double _kTimePickerWidthLandscape = 512.0;
42
43const double _kTimePickerHeightPortrait = 496.0;
44const double _kTimePickerHeightLandscape = 316.0;
45
46const double _kTimePickerHeightPortraitCollapsed = 484.0;
47const double _kTimePickerHeightLandscapeCollapsed = 304.0;
48
49const BoxConstraints _kMinTappableRegion = BoxConstraints(minWidth: 48, minHeight: 48);
50
51enum _TimePickerHeaderId {
52  hour,
53  colon,
54  minute,
55  period, // AM/PM picker
56  dot,
57  hString, // French Canadian "h" literal
58}
59
60/// Provides properties for rendering time picker header fragments.
61@immutable
62class _TimePickerFragmentContext {
63  const _TimePickerFragmentContext({
64    @required this.headerTextTheme,
65    @required this.textDirection,
66    @required this.selectedTime,
67    @required this.mode,
68    @required this.activeColor,
69    @required this.activeStyle,
70    @required this.inactiveColor,
71    @required this.inactiveStyle,
72    @required this.onTimeChange,
73    @required this.onModeChange,
74    @required this.targetPlatform,
75    @required this.use24HourDials,
76  }) : assert(headerTextTheme != null),
77       assert(textDirection != null),
78       assert(selectedTime != null),
79       assert(mode != null),
80       assert(activeColor != null),
81       assert(activeStyle != null),
82       assert(inactiveColor != null),
83       assert(inactiveStyle != null),
84       assert(onTimeChange != null),
85       assert(onModeChange != null),
86       assert(targetPlatform != null),
87       assert(use24HourDials != null);
88
89  final TextTheme headerTextTheme;
90  final TextDirection textDirection;
91  final TimeOfDay selectedTime;
92  final _TimePickerMode mode;
93  final Color activeColor;
94  final TextStyle activeStyle;
95  final Color inactiveColor;
96  final TextStyle inactiveStyle;
97  final ValueChanged<TimeOfDay> onTimeChange;
98  final ValueChanged<_TimePickerMode> onModeChange;
99  final TargetPlatform targetPlatform;
100  final bool use24HourDials;
101}
102
103/// Contains the [widget] and layout properties of an atom of time information,
104/// such as am/pm indicator, hour, minute and string literals appearing in the
105/// formatted time string.
106class _TimePickerHeaderFragment {
107  const _TimePickerHeaderFragment({
108    @required this.layoutId,
109    @required this.widget,
110    this.startMargin = 0.0,
111  }) : assert(layoutId != null),
112       assert(widget != null),
113       assert(startMargin != null);
114
115  /// Identifier used by the custom layout to refer to the widget.
116  final _TimePickerHeaderId layoutId;
117
118  /// The widget that renders a piece of time information.
119  final Widget widget;
120
121  /// Horizontal distance from the fragment appearing at the start of this
122  /// fragment.
123  ///
124  /// This value contributes to the total horizontal width of all fragments
125  /// appearing on the same line, unless it is the first fragment on the line,
126  /// in which case this value is ignored.
127  final double startMargin;
128}
129
130/// An unbreakable part of the time picker header.
131///
132/// When the picker is laid out vertically, [fragments] of the piece are laid
133/// out on the same line, with each piece getting its own line.
134class _TimePickerHeaderPiece {
135  /// Creates a time picker header piece.
136  ///
137  /// All arguments must be non-null. If the piece does not contain a pivot
138  /// fragment, use the value -1 as a convention.
139  const _TimePickerHeaderPiece(this.pivotIndex, this.fragments, { this.bottomMargin = 0.0 })
140    : assert(pivotIndex != null),
141      assert(fragments != null),
142      assert(bottomMargin != null);
143
144  /// Index into the [fragments] list, pointing at the fragment that's centered
145  /// horizontally.
146  final int pivotIndex;
147
148  /// Fragments this piece is made of.
149  final List<_TimePickerHeaderFragment> fragments;
150
151  /// Vertical distance between this piece and the next piece.
152  ///
153  /// This property applies only when the header is laid out vertically.
154  final double bottomMargin;
155}
156
157/// Describes how the time picker header must be formatted.
158///
159/// A [_TimePickerHeaderFormat] is made of multiple [_TimePickerHeaderPiece]s.
160/// A piece is made of multiple [_TimePickerHeaderFragment]s. A fragment has a
161/// widget used to render some time information and contains some layout
162/// properties.
163///
164/// ## Layout rules
165///
166/// Pieces are laid out such that all fragments inside the same piece are laid
167/// out horizontally. Pieces are laid out horizontally if portrait orientation,
168/// and vertically in landscape orientation.
169///
170/// One of the pieces is identified as a _centerpiece_. It is a piece that is
171/// positioned in the center of the header, with all other pieces positioned
172/// to the left or right of it.
173class _TimePickerHeaderFormat {
174  const _TimePickerHeaderFormat(this.centerpieceIndex, this.pieces)
175    : assert(centerpieceIndex != null),
176      assert(pieces != null);
177
178  /// Index into the [pieces] list pointing at the piece that contains the
179  /// pivot fragment.
180  final int centerpieceIndex;
181
182  /// Pieces that constitute a time picker header.
183  final List<_TimePickerHeaderPiece> pieces;
184}
185
186/// Displays the am/pm fragment and provides controls for switching between am
187/// and pm.
188class _DayPeriodControl extends StatelessWidget {
189  const _DayPeriodControl({
190    @required this.fragmentContext,
191    @required this.orientation,
192  });
193
194  final _TimePickerFragmentContext fragmentContext;
195  final Orientation orientation;
196
197  void _togglePeriod() {
198    final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
199    final TimeOfDay newTime = fragmentContext.selectedTime.replacing(hour: newHour);
200    fragmentContext.onTimeChange(newTime);
201  }
202
203  void _setAm(BuildContext context) {
204    if (fragmentContext.selectedTime.period == DayPeriod.am) {
205      return;
206    }
207    if (fragmentContext.targetPlatform == TargetPlatform.android) {
208      _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation);
209    }
210    _togglePeriod();
211  }
212
213  void _setPm(BuildContext context) {
214    if (fragmentContext.selectedTime.period == DayPeriod.pm) {
215      return;
216    }
217    if (fragmentContext.targetPlatform == TargetPlatform.android) {
218      _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation);
219    }
220    _togglePeriod();
221  }
222
223  @override
224  Widget build(BuildContext context) {
225    final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context);
226    final TextTheme headerTextTheme = fragmentContext.headerTextTheme;
227    final TimeOfDay selectedTime = fragmentContext.selectedTime;
228    final Color activeColor = fragmentContext.activeColor;
229    final Color inactiveColor = fragmentContext.inactiveColor;
230    final bool amSelected = selectedTime.period == DayPeriod.am;
231    final TextStyle amStyle = headerTextTheme.subhead.copyWith(
232      color: amSelected ? activeColor: inactiveColor
233    );
234    final TextStyle pmStyle = headerTextTheme.subhead.copyWith(
235      color: !amSelected ? activeColor: inactiveColor
236    );
237    final bool layoutPortrait = orientation == Orientation.portrait;
238
239    final Widget amButton = ConstrainedBox(
240      constraints: _kMinTappableRegion,
241      child: Material(
242        type: MaterialType.transparency,
243        child: InkWell(
244          onTap: Feedback.wrapForTap(() => _setAm(context), context),
245          child: Padding(
246            padding: layoutPortrait ? const EdgeInsets.only(bottom: 2.0) : const EdgeInsets.only(right: 4.0),
247            child: Align(
248              alignment: layoutPortrait ? Alignment.bottomCenter : Alignment.centerRight,
249              widthFactor: 1,
250              heightFactor: 1,
251              child: Semantics(
252                selected: amSelected,
253                child: Text(materialLocalizations.anteMeridiemAbbreviation, style: amStyle)
254              ),
255            ),
256          ),
257        ),
258      ),
259    );
260
261    final Widget pmButton = ConstrainedBox(
262      constraints: _kMinTappableRegion,
263      child: Material(
264        type: MaterialType.transparency,
265        textStyle: pmStyle,
266        child: InkWell(
267          onTap: Feedback.wrapForTap(() => _setPm(context), context),
268          child: Padding(
269            padding: layoutPortrait ? const EdgeInsets.only(top: 2.0) : const EdgeInsets.only(left: 4.0),
270            child: Align(
271              alignment: orientation == Orientation.portrait ? Alignment.topCenter : Alignment.centerLeft,
272              widthFactor: 1,
273              heightFactor: 1,
274              child: Semantics(
275                selected: !amSelected,
276                child: Text(materialLocalizations.postMeridiemAbbreviation, style: pmStyle),
277              ),
278            ),
279          ),
280        ),
281      ),
282    );
283
284    switch (orientation) {
285      case Orientation.portrait:
286        return Column(
287          mainAxisSize: MainAxisSize.min,
288          children: <Widget>[
289            amButton,
290            pmButton,
291          ],
292        );
293
294      case Orientation.landscape:
295        return Row(
296          mainAxisSize: MainAxisSize.min,
297          children: <Widget>[
298            amButton,
299            pmButton,
300          ],
301        );
302    }
303    return null;
304  }
305}
306
307/// Displays the hour fragment.
308///
309/// When tapped changes time picker dial mode to [_TimePickerMode.hour].
310class _HourControl extends StatelessWidget {
311  const _HourControl({
312    @required this.fragmentContext,
313  });
314
315  final _TimePickerFragmentContext fragmentContext;
316
317  @override
318  Widget build(BuildContext context) {
319    assert(debugCheckHasMediaQuery(context));
320    final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
321    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
322    final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour
323        ? fragmentContext.activeStyle
324        : fragmentContext.inactiveStyle;
325    final String formattedHour = localizations.formatHour(
326      fragmentContext.selectedTime,
327      alwaysUse24HourFormat: alwaysUse24HourFormat,
328    );
329
330    TimeOfDay hoursFromSelected(int hoursToAdd) {
331      if (fragmentContext.use24HourDials) {
332        final int selectedHour = fragmentContext.selectedTime.hour;
333        return fragmentContext.selectedTime.replacing(
334          hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay,
335        );
336      } else {
337        // Cycle 1 through 12 without changing day period.
338        final int periodOffset = fragmentContext.selectedTime.periodOffset;
339        final int hours = fragmentContext.selectedTime.hourOfPeriod;
340        return fragmentContext.selectedTime.replacing(
341          hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod,
342        );
343      }
344    }
345
346    final TimeOfDay nextHour = hoursFromSelected(1);
347    final String formattedNextHour = localizations.formatHour(
348      nextHour,
349      alwaysUse24HourFormat: alwaysUse24HourFormat,
350    );
351    final TimeOfDay previousHour = hoursFromSelected(-1);
352    final String formattedPreviousHour = localizations.formatHour(
353      previousHour,
354      alwaysUse24HourFormat: alwaysUse24HourFormat,
355    );
356
357    return Semantics(
358      hint: localizations.timePickerHourModeAnnouncement,
359      value: formattedHour,
360      excludeSemantics: true,
361      increasedValue: formattedNextHour,
362      onIncrease: () {
363        fragmentContext.onTimeChange(nextHour);
364      },
365      decreasedValue: formattedPreviousHour,
366      onDecrease: () {
367        fragmentContext.onTimeChange(previousHour);
368      },
369      child: ConstrainedBox(
370        constraints: _kMinTappableRegion,
371        child: Material(
372          type: MaterialType.transparency,
373          child: InkWell(
374            onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
375            child: Text(formattedHour, style: hourStyle, textAlign: TextAlign.end),
376          ),
377        ),
378      ),
379    );
380  }
381}
382
383/// A passive fragment showing a string value.
384class _StringFragment extends StatelessWidget {
385  const _StringFragment({
386    @required this.fragmentContext,
387    @required this.value,
388  });
389
390  final _TimePickerFragmentContext fragmentContext;
391  final String value;
392
393  @override
394  Widget build(BuildContext context) {
395    return ExcludeSemantics(
396      child: Text(value, style: fragmentContext.inactiveStyle),
397    );
398  }
399}
400
401/// Displays the minute fragment.
402///
403/// When tapped changes time picker dial mode to [_TimePickerMode.minute].
404class _MinuteControl extends StatelessWidget {
405  const _MinuteControl({
406    @required this.fragmentContext,
407  });
408
409  final _TimePickerFragmentContext fragmentContext;
410
411  @override
412  Widget build(BuildContext context) {
413    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
414    final TextStyle minuteStyle = fragmentContext.mode == _TimePickerMode.minute
415        ? fragmentContext.activeStyle
416        : fragmentContext.inactiveStyle;
417    final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime);
418    final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing(
419      minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour,
420    );
421    final String formattedNextMinute = localizations.formatMinute(nextMinute);
422    final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing(
423      minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour,
424    );
425    final String formattedPreviousMinute = localizations.formatMinute(previousMinute);
426
427    return Semantics(
428      excludeSemantics: true,
429      hint: localizations.timePickerMinuteModeAnnouncement,
430      value: formattedMinute,
431      increasedValue: formattedNextMinute,
432      onIncrease: () {
433        fragmentContext.onTimeChange(nextMinute);
434      },
435      decreasedValue: formattedPreviousMinute,
436      onDecrease: () {
437        fragmentContext.onTimeChange(previousMinute);
438      },
439      child: ConstrainedBox(
440        constraints: _kMinTappableRegion,
441        child: Material(
442          type: MaterialType.transparency,
443          child: InkWell(
444            onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
445            child: Text(formattedMinute, style: minuteStyle, textAlign: TextAlign.start),
446          ),
447        ),
448      ),
449    );
450  }
451}
452
453/// Provides time picker header layout configuration for the given
454/// [timeOfDayFormat] passing [context] to each widget in the
455/// configuration.
456///
457/// The [timeOfDayFormat] and [context] arguments must not be null.
458_TimePickerHeaderFormat _buildHeaderFormat(
459  TimeOfDayFormat timeOfDayFormat,
460  _TimePickerFragmentContext context,
461  Orientation orientation
462) {
463
464  // Creates an hour fragment.
465  _TimePickerHeaderFragment hour() {
466    return _TimePickerHeaderFragment(
467      layoutId: _TimePickerHeaderId.hour,
468      widget: _HourControl(fragmentContext: context),
469    );
470  }
471
472  // Creates a minute fragment.
473  _TimePickerHeaderFragment minute() {
474    return _TimePickerHeaderFragment(
475      layoutId: _TimePickerHeaderId.minute,
476      widget: _MinuteControl(fragmentContext: context),
477    );
478  }
479
480  // Creates a string fragment.
481  _TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) {
482    return _TimePickerHeaderFragment(
483      layoutId: layoutId,
484      widget: _StringFragment(
485        fragmentContext: context,
486        value: value,
487      ),
488    );
489  }
490
491  // Creates an am/pm fragment.
492  _TimePickerHeaderFragment dayPeriod() {
493    return _TimePickerHeaderFragment(
494      layoutId: _TimePickerHeaderId.period,
495      widget: _DayPeriodControl(fragmentContext: context, orientation: orientation),
496    );
497  }
498
499  // Convenience function for creating a time header format with up to two pieces.
500  _TimePickerHeaderFormat format(
501    _TimePickerHeaderPiece piece1, [
502    _TimePickerHeaderPiece piece2,
503  ]) {
504    final List<_TimePickerHeaderPiece> pieces = <_TimePickerHeaderPiece>[];
505    switch (context.textDirection) {
506      case TextDirection.ltr:
507        pieces.add(piece1);
508        if (piece2 != null)
509          pieces.add(piece2);
510        break;
511      case TextDirection.rtl:
512        if (piece2 != null)
513          pieces.add(piece2);
514        pieces.add(piece1);
515        break;
516    }
517    int centerpieceIndex;
518    for (int i = 0; i < pieces.length; i += 1) {
519      if (pieces[i].pivotIndex >= 0) {
520        centerpieceIndex = i;
521      }
522    }
523    assert(centerpieceIndex != null);
524    return _TimePickerHeaderFormat(centerpieceIndex, pieces);
525  }
526
527  // Convenience function for creating a time header piece with up to three fragments.
528  _TimePickerHeaderPiece piece({
529    int pivotIndex = -1,
530    double bottomMargin = 0.0,
531    _TimePickerHeaderFragment fragment1,
532    _TimePickerHeaderFragment fragment2,
533    _TimePickerHeaderFragment fragment3,
534  }) {
535    final List<_TimePickerHeaderFragment> fragments = <_TimePickerHeaderFragment>[fragment1];
536    if (fragment2 != null) {
537      fragments.add(fragment2);
538      if (fragment3 != null)
539        fragments.add(fragment3);
540    }
541    return _TimePickerHeaderPiece(pivotIndex, fragments, bottomMargin: bottomMargin);
542  }
543
544  switch (timeOfDayFormat) {
545    case TimeOfDayFormat.h_colon_mm_space_a:
546      return format(
547        piece(
548          pivotIndex: 1,
549          fragment1: hour(),
550          fragment2: string(_TimePickerHeaderId.colon, ':'),
551          fragment3: minute(),
552        ),
553        piece(
554          fragment1: dayPeriod(),
555        ),
556      );
557    case TimeOfDayFormat.H_colon_mm:
558      return format(piece(
559        pivotIndex: 1,
560        fragment1: hour(),
561        fragment2: string(_TimePickerHeaderId.colon, ':'),
562        fragment3: minute(),
563      ));
564    case TimeOfDayFormat.HH_dot_mm:
565      return format(piece(
566        pivotIndex: 1,
567        fragment1: hour(),
568        fragment2: string(_TimePickerHeaderId.dot, '.'),
569        fragment3: minute(),
570      ));
571    case TimeOfDayFormat.a_space_h_colon_mm:
572      return format(
573        piece(
574          fragment1: dayPeriod(),
575        ),
576        piece(
577          pivotIndex: 1,
578          fragment1: hour(),
579          fragment2: string(_TimePickerHeaderId.colon, ':'),
580          fragment3: minute(),
581        ),
582      );
583    case TimeOfDayFormat.frenchCanadian:
584      return format(piece(
585        pivotIndex: 1,
586        fragment1: hour(),
587        fragment2: string(_TimePickerHeaderId.hString, 'h'),
588        fragment3: minute(),
589      ));
590    case TimeOfDayFormat.HH_colon_mm:
591      return format(piece(
592        pivotIndex: 1,
593        fragment1: hour(),
594        fragment2: string(_TimePickerHeaderId.colon, ':'),
595        fragment3: minute(),
596      ));
597  }
598
599  return null;
600}
601
602class _TimePickerHeaderLayout extends MultiChildLayoutDelegate {
603  _TimePickerHeaderLayout(this.orientation, this.format)
604    : assert(orientation != null),
605      assert(format != null);
606
607  final Orientation orientation;
608  final _TimePickerHeaderFormat format;
609
610  @override
611  void performLayout(Size size) {
612    final BoxConstraints constraints = BoxConstraints.loose(size);
613
614    switch (orientation) {
615      case Orientation.portrait:
616        _layoutHorizontally(size, constraints);
617        break;
618      case Orientation.landscape:
619        _layoutVertically(size, constraints);
620        break;
621    }
622  }
623
624  void _layoutHorizontally(Size size, BoxConstraints constraints) {
625    final List<_TimePickerHeaderFragment> fragmentsFlattened = <_TimePickerHeaderFragment>[];
626    final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{};
627    int pivotIndex = 0;
628    for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
629      final _TimePickerHeaderPiece piece = format.pieces[pieceIndex];
630      for (final _TimePickerHeaderFragment fragment in piece.fragments) {
631        childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints);
632        fragmentsFlattened.add(fragment);
633      }
634
635      if (pieceIndex == format.centerpieceIndex)
636        pivotIndex += format.pieces[format.centerpieceIndex].pivotIndex;
637      else if (pieceIndex < format.centerpieceIndex)
638        pivotIndex += piece.fragments.length;
639    }
640
641    _positionPivoted(size.width, size.height / 2.0, childSizes, fragmentsFlattened, pivotIndex);
642  }
643
644  void _layoutVertically(Size size, BoxConstraints constraints) {
645    final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{};
646    final List<double> pieceHeights = <double>[];
647    double height = 0.0;
648    double margin = 0.0;
649    for (final _TimePickerHeaderPiece piece in format.pieces) {
650      double pieceHeight = 0.0;
651      for (final _TimePickerHeaderFragment fragment in piece.fragments) {
652        final Size childSize = childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints);
653        pieceHeight = math.max(pieceHeight, childSize.height);
654      }
655      pieceHeights.add(pieceHeight);
656      height += pieceHeight + margin;
657      // Delay application of margin until next piece because margin of the
658      // bottom-most piece should not contribute to the size.
659      margin = piece.bottomMargin;
660    }
661
662    final _TimePickerHeaderPiece centerpiece = format.pieces[format.centerpieceIndex];
663    double y = (size.height - height) / 2.0;
664    for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
665      final double pieceVerticalCenter = y + pieceHeights[pieceIndex] / 2.0;
666      if (pieceIndex != format.centerpieceIndex)
667        _positionPiece(size.width, pieceVerticalCenter, childSizes, format.pieces[pieceIndex].fragments);
668      else
669        _positionPivoted(size.width, pieceVerticalCenter, childSizes, centerpiece.fragments, centerpiece.pivotIndex);
670
671      y += pieceHeights[pieceIndex] + format.pieces[pieceIndex].bottomMargin;
672    }
673  }
674
675  void _positionPivoted(double width, double y, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments, int pivotIndex) {
676    double tailWidth = childSizes[fragments[pivotIndex].layoutId].width / 2.0;
677    for (_TimePickerHeaderFragment fragment in fragments.skip(pivotIndex + 1)) {
678      tailWidth += childSizes[fragment.layoutId].width + fragment.startMargin;
679    }
680
681    double x = width / 2.0 + tailWidth;
682    x = math.min(x, width);
683    for (int i = fragments.length - 1; i >= 0; i -= 1) {
684      final _TimePickerHeaderFragment fragment = fragments[i];
685      final Size childSize = childSizes[fragment.layoutId];
686      x -= childSize.width;
687      positionChild(fragment.layoutId, Offset(x, y - childSize.height / 2.0));
688      x -= fragment.startMargin;
689    }
690  }
691
692  void _positionPiece(double width, double centeredAroundY, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments) {
693    double pieceWidth = 0.0;
694    double nextMargin = 0.0;
695    for (_TimePickerHeaderFragment fragment in fragments) {
696      final Size childSize = childSizes[fragment.layoutId];
697      pieceWidth += childSize.width + nextMargin;
698      // Delay application of margin until next element because margin of the
699      // left-most fragment should not contribute to the size.
700      nextMargin = fragment.startMargin;
701    }
702    double x = (width + pieceWidth) / 2.0;
703    for (int i = fragments.length - 1; i >= 0; i -= 1) {
704      final _TimePickerHeaderFragment fragment = fragments[i];
705      final Size childSize = childSizes[fragment.layoutId];
706      x -= childSize.width;
707      positionChild(fragment.layoutId, Offset(x, centeredAroundY - childSize.height / 2.0));
708      x -= fragment.startMargin;
709    }
710  }
711
712  @override
713  bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation || format != oldDelegate.format;
714}
715
716class _TimePickerHeader extends StatelessWidget {
717  const _TimePickerHeader({
718    @required this.selectedTime,
719    @required this.mode,
720    @required this.orientation,
721    @required this.onModeChanged,
722    @required this.onChanged,
723    @required this.use24HourDials,
724  }) : assert(selectedTime != null),
725       assert(mode != null),
726       assert(orientation != null),
727       assert(use24HourDials != null);
728
729  final TimeOfDay selectedTime;
730  final _TimePickerMode mode;
731  final Orientation orientation;
732  final ValueChanged<_TimePickerMode> onModeChanged;
733  final ValueChanged<TimeOfDay> onChanged;
734  final bool use24HourDials;
735
736  void _handleChangeMode(_TimePickerMode value) {
737    if (value != mode)
738      onModeChanged(value);
739  }
740
741  TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) {
742    // These font sizes aren't listed in the spec explicitly. I worked them out
743    // by measuring the text using a screen ruler and comparing them to the
744    // screen shots of the time picker in the spec.
745    assert(orientation != null);
746    switch (orientation) {
747      case Orientation.portrait:
748        return headerTextTheme.display3.copyWith(fontSize: 60.0);
749      case Orientation.landscape:
750        return headerTextTheme.display2.copyWith(fontSize: 50.0);
751    }
752    return null;
753  }
754
755  @override
756  Widget build(BuildContext context) {
757    assert(debugCheckHasMediaQuery(context));
758    final ThemeData themeData = Theme.of(context);
759    final MediaQueryData media = MediaQuery.of(context);
760    final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context)
761        .timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
762
763    EdgeInsets padding;
764    double height;
765    double width;
766
767    assert(orientation != null);
768    switch (orientation) {
769      case Orientation.portrait:
770        height = _kTimePickerHeaderPortraitHeight;
771        padding = const EdgeInsets.symmetric(horizontal: 24.0);
772        break;
773      case Orientation.landscape:
774        width = _kTimePickerHeaderLandscapeWidth;
775        padding = const EdgeInsets.symmetric(horizontal: 16.0);
776        break;
777    }
778
779    Color backgroundColor;
780    switch (themeData.brightness) {
781      case Brightness.light:
782        backgroundColor = themeData.primaryColor;
783        break;
784      case Brightness.dark:
785        backgroundColor = themeData.backgroundColor;
786        break;
787    }
788
789    Color activeColor;
790    Color inactiveColor;
791    switch (themeData.primaryColorBrightness) {
792      case Brightness.light:
793        activeColor = Colors.black87;
794        inactiveColor = Colors.black54;
795        break;
796      case Brightness.dark:
797        activeColor = Colors.white;
798        inactiveColor = Colors.white70;
799        break;
800    }
801
802    final TextTheme headerTextTheme = themeData.primaryTextTheme;
803    final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme);
804    final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext(
805      headerTextTheme: headerTextTheme,
806      textDirection: Directionality.of(context),
807      selectedTime: selectedTime,
808      mode: mode,
809      activeColor: activeColor,
810      activeStyle: baseHeaderStyle.copyWith(color: activeColor),
811      inactiveColor: inactiveColor,
812      inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor),
813      onTimeChange: onChanged,
814      onModeChange: _handleChangeMode,
815      targetPlatform: themeData.platform,
816      use24HourDials: use24HourDials,
817    );
818
819    final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext, orientation);
820
821    return Container(
822      width: width,
823      height: height,
824      padding: padding,
825      color: backgroundColor,
826      child: CustomMultiChildLayout(
827        delegate: _TimePickerHeaderLayout(orientation, format),
828        children: format.pieces
829          .expand<_TimePickerHeaderFragment>((_TimePickerHeaderPiece piece) => piece.fragments)
830          .map<Widget>((_TimePickerHeaderFragment fragment) {
831            return LayoutId(
832              id: fragment.layoutId,
833              child: fragment.widget,
834            );
835          })
836          .toList(),
837      ),
838    );
839  }
840}
841
842enum _DialRing {
843  outer,
844  inner,
845}
846
847class _TappableLabel {
848  _TappableLabel({
849    @required this.value,
850    @required this.painter,
851    @required this.onTap,
852  });
853
854  /// The value this label is displaying.
855  final int value;
856
857  /// Paints the text of the label.
858  final TextPainter painter;
859
860  /// Called when a tap gesture is detected on the label.
861  final VoidCallback onTap;
862}
863
864class _DialPainter extends CustomPainter {
865  const _DialPainter({
866    @required this.primaryOuterLabels,
867    @required this.primaryInnerLabels,
868    @required this.secondaryOuterLabels,
869    @required this.secondaryInnerLabels,
870    @required this.backgroundColor,
871    @required this.accentColor,
872    @required this.theta,
873    @required this.activeRing,
874    @required this.textDirection,
875    @required this.selectedValue,
876  });
877
878  final List<_TappableLabel> primaryOuterLabels;
879  final List<_TappableLabel> primaryInnerLabels;
880  final List<_TappableLabel> secondaryOuterLabels;
881  final List<_TappableLabel> secondaryInnerLabels;
882  final Color backgroundColor;
883  final Color accentColor;
884  final double theta;
885  final _DialRing activeRing;
886  final TextDirection textDirection;
887  final int selectedValue;
888
889  @override
890  void paint(Canvas canvas, Size size) {
891    final double radius = size.shortestSide / 2.0;
892    final Offset center = Offset(size.width / 2.0, size.height / 2.0);
893    final Offset centerPoint = center;
894    canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor);
895
896    const double labelPadding = 24.0;
897    final double outerLabelRadius = radius - labelPadding;
898    final double innerLabelRadius = radius - labelPadding * 2.5;
899    Offset getOffsetForTheta(double theta, _DialRing ring) {
900      double labelRadius;
901      switch (ring) {
902        case _DialRing.outer:
903          labelRadius = outerLabelRadius;
904          break;
905        case _DialRing.inner:
906          labelRadius = innerLabelRadius;
907          break;
908      }
909      return center + Offset(labelRadius * math.cos(theta),
910                                 -labelRadius * math.sin(theta));
911    }
912
913    void paintLabels(List<_TappableLabel> labels, _DialRing ring) {
914      if (labels == null)
915        return;
916      final double labelThetaIncrement = -_kTwoPi / labels.length;
917      double labelTheta = math.pi / 2.0;
918
919      for (_TappableLabel label in labels) {
920        final TextPainter labelPainter = label.painter;
921        final Offset labelOffset = Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0);
922        labelPainter.paint(canvas, getOffsetForTheta(labelTheta, ring) + labelOffset);
923        labelTheta += labelThetaIncrement;
924      }
925    }
926
927    paintLabels(primaryOuterLabels, _DialRing.outer);
928    paintLabels(primaryInnerLabels, _DialRing.inner);
929
930    final Paint selectorPaint = Paint()
931      ..color = accentColor;
932    final Offset focusedPoint = getOffsetForTheta(theta, activeRing);
933    const double focusedRadius = labelPadding - 4.0;
934    canvas.drawCircle(centerPoint, 4.0, selectorPaint);
935    canvas.drawCircle(focusedPoint, focusedRadius, selectorPaint);
936    selectorPaint.strokeWidth = 2.0;
937    canvas.drawLine(centerPoint, focusedPoint, selectorPaint);
938
939    final Rect focusedRect = Rect.fromCircle(
940      center: focusedPoint, radius: focusedRadius,
941    );
942    canvas
943      ..save()
944      ..clipPath(Path()..addOval(focusedRect));
945    paintLabels(secondaryOuterLabels, _DialRing.outer);
946    paintLabels(secondaryInnerLabels, _DialRing.inner);
947    canvas.restore();
948  }
949
950  static const double _semanticNodeSizeScale = 1.5;
951
952  @override
953  SemanticsBuilderCallback get semanticsBuilder => _buildSemantics;
954
955  /// Creates semantics nodes for the hour/minute labels painted on the dial.
956  ///
957  /// The nodes are positioned on top of the text and their size is
958  /// [_semanticNodeSizeScale] bigger than those of the text boxes to provide
959  /// bigger tap area.
960  List<CustomPainterSemantics> _buildSemantics(Size size) {
961    final double radius = size.shortestSide / 2.0;
962    final Offset center = Offset(size.width / 2.0, size.height / 2.0);
963    const double labelPadding = 24.0;
964    final double outerLabelRadius = radius - labelPadding;
965    final double innerLabelRadius = radius - labelPadding * 2.5;
966
967    Offset getOffsetForTheta(double theta, _DialRing ring) {
968      double labelRadius;
969      switch (ring) {
970        case _DialRing.outer:
971          labelRadius = outerLabelRadius;
972          break;
973        case _DialRing.inner:
974          labelRadius = innerLabelRadius;
975          break;
976      }
977      return center + Offset(labelRadius * math.cos(theta),
978          -labelRadius * math.sin(theta));
979    }
980
981    final List<CustomPainterSemantics> nodes = <CustomPainterSemantics>[];
982
983    void paintLabels(List<_TappableLabel> labels, _DialRing ring) {
984      if (labels == null)
985        return;
986      final double labelThetaIncrement = -_kTwoPi / labels.length;
987      final double ordinalOffset = ring == _DialRing.inner ? 12.0 : 0.0;
988      double labelTheta = math.pi / 2.0;
989
990      for (int i = 0; i < labels.length; i++) {
991        final _TappableLabel label = labels[i];
992        final TextPainter labelPainter = label.painter;
993        final double width = labelPainter.width * _semanticNodeSizeScale;
994        final double height = labelPainter.height * _semanticNodeSizeScale;
995        final Offset nodeOffset = getOffsetForTheta(labelTheta, ring) + Offset(-width / 2.0, -height / 2.0);
996        final TextSpan textSpan = labelPainter.text;
997        final CustomPainterSemantics node = CustomPainterSemantics(
998          rect: Rect.fromLTRB(
999            nodeOffset.dx - 24.0 + width / 2,
1000            nodeOffset.dy - 24.0 + height / 2,
1001            nodeOffset.dx + 24.0 + width / 2,
1002            nodeOffset.dy + 24.0 + height / 2,
1003          ),
1004          properties: SemanticsProperties(
1005            sortKey: OrdinalSortKey(i.toDouble() + ordinalOffset),
1006            selected: label.value == selectedValue,
1007            value: textSpan?.text,
1008            textDirection: textDirection,
1009            onTap: label.onTap,
1010          ),
1011          tags: const <SemanticsTag>{
1012            // Used by tests to find this node.
1013            SemanticsTag('dial-label'),
1014          },
1015        );
1016        nodes.add(node);
1017        labelTheta += labelThetaIncrement;
1018      }
1019    }
1020
1021    paintLabels(primaryOuterLabels, _DialRing.outer);
1022    paintLabels(primaryInnerLabels, _DialRing.inner);
1023
1024    return nodes;
1025  }
1026
1027  @override
1028  bool shouldRepaint(_DialPainter oldPainter) {
1029    return oldPainter.primaryOuterLabels != primaryOuterLabels
1030        || oldPainter.primaryInnerLabels != primaryInnerLabels
1031        || oldPainter.secondaryOuterLabels != secondaryOuterLabels
1032        || oldPainter.secondaryInnerLabels != secondaryInnerLabels
1033        || oldPainter.backgroundColor != backgroundColor
1034        || oldPainter.accentColor != accentColor
1035        || oldPainter.theta != theta
1036        || oldPainter.activeRing != activeRing;
1037  }
1038}
1039
1040class _Dial extends StatefulWidget {
1041  const _Dial({
1042    @required this.selectedTime,
1043    @required this.mode,
1044    @required this.use24HourDials,
1045    @required this.onChanged,
1046    @required this.onHourSelected,
1047  }) : assert(selectedTime != null),
1048       assert(mode != null),
1049       assert(use24HourDials != null);
1050
1051  final TimeOfDay selectedTime;
1052  final _TimePickerMode mode;
1053  final bool use24HourDials;
1054  final ValueChanged<TimeOfDay> onChanged;
1055  final VoidCallback onHourSelected;
1056
1057  @override
1058  _DialState createState() => _DialState();
1059}
1060
1061class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
1062  @override
1063  void initState() {
1064    super.initState();
1065    _updateDialRingFromWidget();
1066    _thetaController = AnimationController(
1067      duration: _kDialAnimateDuration,
1068      vsync: this,
1069    );
1070    _thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime));
1071    _theta = _thetaController
1072      .drive(CurveTween(curve: Curves.fastOutSlowIn))
1073      .drive(_thetaTween)
1074      ..addListener(() => setState(() { /* _theta.value has changed */ }));
1075  }
1076
1077  ThemeData themeData;
1078  MaterialLocalizations localizations;
1079  MediaQueryData media;
1080
1081  @override
1082  void didChangeDependencies() {
1083    super.didChangeDependencies();
1084    assert(debugCheckHasMediaQuery(context));
1085    themeData = Theme.of(context);
1086    localizations = MaterialLocalizations.of(context);
1087    media = MediaQuery.of(context);
1088  }
1089
1090  @override
1091  void didUpdateWidget(_Dial oldWidget) {
1092    super.didUpdateWidget(oldWidget);
1093    _updateDialRingFromWidget();
1094    if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) {
1095      if (!_dragging)
1096        _animateTo(_getThetaForTime(widget.selectedTime));
1097    }
1098  }
1099
1100  void _updateDialRingFromWidget() {
1101    if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
1102      _activeRing = widget.selectedTime.hour >= 1 && widget.selectedTime.hour <= 12
1103          ? _DialRing.inner
1104          : _DialRing.outer;
1105    } else {
1106      _activeRing = _DialRing.outer;
1107    }
1108  }
1109
1110  @override
1111  void dispose() {
1112    _thetaController.dispose();
1113    super.dispose();
1114  }
1115
1116  Tween<double> _thetaTween;
1117  Animation<double> _theta;
1118  AnimationController _thetaController;
1119  bool _dragging = false;
1120
1121  static double _nearest(double target, double a, double b) {
1122    return ((target - a).abs() < (target - b).abs()) ? a : b;
1123  }
1124
1125  void _animateTo(double targetTheta) {
1126    final double currentTheta = _theta.value;
1127    double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi);
1128    beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi);
1129    _thetaTween
1130      ..begin = beginTheta
1131      ..end = targetTheta;
1132    _thetaController
1133      ..value = 0.0
1134      ..forward();
1135  }
1136
1137  double _getThetaForTime(TimeOfDay time) {
1138    final double fraction = widget.mode == _TimePickerMode.hour
1139      ? (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod
1140      : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
1141    return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi;
1142  }
1143
1144  TimeOfDay _getTimeForTheta(double theta) {
1145    final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
1146    if (widget.mode == _TimePickerMode.hour) {
1147      int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod;
1148      if (widget.use24HourDials) {
1149        if (_activeRing == _DialRing.outer) {
1150          if (newHour != 0)
1151            newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
1152        } else if (newHour == 0) {
1153          newHour = TimeOfDay.hoursPerPeriod;
1154        }
1155      } else {
1156        newHour = newHour + widget.selectedTime.periodOffset;
1157      }
1158      return widget.selectedTime.replacing(hour: newHour);
1159    } else {
1160      return widget.selectedTime.replacing(
1161        minute: (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour
1162      );
1163    }
1164  }
1165
1166  TimeOfDay _notifyOnChangedIfNeeded() {
1167    final TimeOfDay current = _getTimeForTheta(_theta.value);
1168    if (widget.onChanged == null)
1169      return current;
1170    if (current != widget.selectedTime)
1171      widget.onChanged(current);
1172    return current;
1173  }
1174
1175  void _updateThetaForPan() {
1176    setState(() {
1177      final Offset offset = _position - _center;
1178      final double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi;
1179      _thetaTween
1180        ..begin = angle
1181        ..end = angle; // The controller doesn't animate during the pan gesture.
1182      final RenderBox box = context.findRenderObject();
1183      final double radius = box.size.shortestSide / 2.0;
1184      if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
1185        if (offset.distance * 1.5 < radius)
1186          _activeRing = _DialRing.inner;
1187        else
1188          _activeRing = _DialRing.outer;
1189      }
1190    });
1191  }
1192
1193  Offset _position;
1194  Offset _center;
1195  _DialRing _activeRing = _DialRing.outer;
1196
1197  void _handlePanStart(DragStartDetails details) {
1198    assert(!_dragging);
1199    _dragging = true;
1200    final RenderBox box = context.findRenderObject();
1201    _position = box.globalToLocal(details.globalPosition);
1202    _center = box.size.center(Offset.zero);
1203    _updateThetaForPan();
1204    _notifyOnChangedIfNeeded();
1205  }
1206
1207  void _handlePanUpdate(DragUpdateDetails details) {
1208    _position += details.delta;
1209    _updateThetaForPan();
1210    _notifyOnChangedIfNeeded();
1211  }
1212
1213  void _handlePanEnd(DragEndDetails details) {
1214    assert(_dragging);
1215    _dragging = false;
1216    _position = null;
1217    _center = null;
1218    _animateTo(_getThetaForTime(widget.selectedTime));
1219    if (widget.mode == _TimePickerMode.hour) {
1220      if (widget.onHourSelected != null) {
1221        widget.onHourSelected();
1222      }
1223    }
1224  }
1225
1226  void _handleTapUp(TapUpDetails details) {
1227    final RenderBox box = context.findRenderObject();
1228    _position = box.globalToLocal(details.globalPosition);
1229    _center = box.size.center(Offset.zero);
1230    _updateThetaForPan();
1231    final TimeOfDay newTime = _notifyOnChangedIfNeeded();
1232    if (widget.mode == _TimePickerMode.hour) {
1233      if (widget.use24HourDials) {
1234        _announceToAccessibility(context, localizations.formatDecimal(newTime.hour));
1235      } else {
1236        _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod));
1237      }
1238      if (widget.onHourSelected != null) {
1239        widget.onHourSelected();
1240      }
1241    } else {
1242      _announceToAccessibility(context, localizations.formatDecimal(newTime.minute));
1243    }
1244    _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value)));
1245    _dragging = false;
1246    _position = null;
1247    _center = null;
1248  }
1249
1250  void _selectHour(int hour) {
1251    _announceToAccessibility(context, localizations.formatDecimal(hour));
1252    TimeOfDay time;
1253    if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
1254      _activeRing = hour >= 1 && hour <= 12
1255          ? _DialRing.inner
1256          : _DialRing.outer;
1257      time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
1258    } else {
1259      _activeRing = _DialRing.outer;
1260      if (widget.selectedTime.period == DayPeriod.am) {
1261        time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
1262      } else {
1263        time = TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute);
1264      }
1265    }
1266    final double angle = _getThetaForTime(time);
1267    _thetaTween
1268      ..begin = angle
1269      ..end = angle;
1270    _notifyOnChangedIfNeeded();
1271  }
1272
1273  void _selectMinute(int minute) {
1274    _announceToAccessibility(context, localizations.formatDecimal(minute));
1275    final TimeOfDay time = TimeOfDay(
1276      hour: widget.selectedTime.hour,
1277      minute: minute,
1278    );
1279    final double angle = _getThetaForTime(time);
1280    _thetaTween
1281      ..begin = angle
1282      ..end = angle;
1283    _notifyOnChangedIfNeeded();
1284  }
1285
1286  static const List<TimeOfDay> _amHours = <TimeOfDay>[
1287    TimeOfDay(hour: 12, minute: 0),
1288    TimeOfDay(hour: 1, minute: 0),
1289    TimeOfDay(hour: 2, minute: 0),
1290    TimeOfDay(hour: 3, minute: 0),
1291    TimeOfDay(hour: 4, minute: 0),
1292    TimeOfDay(hour: 5, minute: 0),
1293    TimeOfDay(hour: 6, minute: 0),
1294    TimeOfDay(hour: 7, minute: 0),
1295    TimeOfDay(hour: 8, minute: 0),
1296    TimeOfDay(hour: 9, minute: 0),
1297    TimeOfDay(hour: 10, minute: 0),
1298    TimeOfDay(hour: 11, minute: 0),
1299  ];
1300
1301  static const List<TimeOfDay> _pmHours = <TimeOfDay>[
1302    TimeOfDay(hour: 0, minute: 0),
1303    TimeOfDay(hour: 13, minute: 0),
1304    TimeOfDay(hour: 14, minute: 0),
1305    TimeOfDay(hour: 15, minute: 0),
1306    TimeOfDay(hour: 16, minute: 0),
1307    TimeOfDay(hour: 17, minute: 0),
1308    TimeOfDay(hour: 18, minute: 0),
1309    TimeOfDay(hour: 19, minute: 0),
1310    TimeOfDay(hour: 20, minute: 0),
1311    TimeOfDay(hour: 21, minute: 0),
1312    TimeOfDay(hour: 22, minute: 0),
1313    TimeOfDay(hour: 23, minute: 0),
1314  ];
1315
1316  _TappableLabel _buildTappableLabel(TextTheme textTheme, int value, String label, VoidCallback onTap) {
1317    final TextStyle style = textTheme.subhead;
1318    // TODO(abarth): Handle textScaleFactor.
1319    // https://github.com/flutter/flutter/issues/5939
1320    return _TappableLabel(
1321      value: value,
1322      painter: TextPainter(
1323        text: TextSpan(style: style, text: label),
1324        textDirection: TextDirection.ltr,
1325      )..layout(),
1326      onTap: onTap,
1327    );
1328  }
1329
1330  List<_TappableLabel> _build24HourInnerRing(TextTheme textTheme) {
1331    final List<_TappableLabel> labels = <_TappableLabel>[];
1332    for (TimeOfDay timeOfDay in _amHours) {
1333      labels.add(_buildTappableLabel(
1334        textTheme,
1335        timeOfDay.hour,
1336        localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
1337        () {
1338          _selectHour(timeOfDay.hour);
1339        },
1340      ));
1341    }
1342    return labels;
1343  }
1344
1345  List<_TappableLabel> _build24HourOuterRing(TextTheme textTheme) {
1346    final List<_TappableLabel> labels = <_TappableLabel>[];
1347    for (TimeOfDay timeOfDay in _pmHours) {
1348      labels.add(_buildTappableLabel(
1349        textTheme,
1350        timeOfDay.hour,
1351        localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
1352        () {
1353          _selectHour(timeOfDay.hour);
1354        },
1355      ));
1356    }
1357    return labels;
1358  }
1359
1360  List<_TappableLabel> _build12HourOuterRing(TextTheme textTheme) {
1361    final List<_TappableLabel> labels = <_TappableLabel>[];
1362    for (TimeOfDay timeOfDay in _amHours) {
1363      labels.add(_buildTappableLabel(
1364        textTheme,
1365        timeOfDay.hour,
1366        localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
1367        () {
1368          _selectHour(timeOfDay.hour);
1369        },
1370      ));
1371    }
1372    return labels;
1373  }
1374
1375  List<_TappableLabel> _buildMinutes(TextTheme textTheme) {
1376    const List<TimeOfDay> _minuteMarkerValues = <TimeOfDay>[
1377      TimeOfDay(hour: 0, minute: 0),
1378      TimeOfDay(hour: 0, minute: 5),
1379      TimeOfDay(hour: 0, minute: 10),
1380      TimeOfDay(hour: 0, minute: 15),
1381      TimeOfDay(hour: 0, minute: 20),
1382      TimeOfDay(hour: 0, minute: 25),
1383      TimeOfDay(hour: 0, minute: 30),
1384      TimeOfDay(hour: 0, minute: 35),
1385      TimeOfDay(hour: 0, minute: 40),
1386      TimeOfDay(hour: 0, minute: 45),
1387      TimeOfDay(hour: 0, minute: 50),
1388      TimeOfDay(hour: 0, minute: 55),
1389    ];
1390
1391    final List<_TappableLabel> labels = <_TappableLabel>[];
1392    for (TimeOfDay timeOfDay in _minuteMarkerValues) {
1393      labels.add(_buildTappableLabel(
1394        textTheme,
1395        timeOfDay.minute,
1396        localizations.formatMinute(timeOfDay),
1397        () {
1398          _selectMinute(timeOfDay.minute);
1399        },
1400      ));
1401    }
1402    return labels;
1403  }
1404
1405  @override
1406  Widget build(BuildContext context) {
1407    Color backgroundColor;
1408    switch (themeData.brightness) {
1409      case Brightness.light:
1410        backgroundColor = Colors.grey[200];
1411        break;
1412      case Brightness.dark:
1413        backgroundColor = themeData.backgroundColor;
1414        break;
1415    }
1416
1417    final ThemeData theme = Theme.of(context);
1418    List<_TappableLabel> primaryOuterLabels;
1419    List<_TappableLabel> primaryInnerLabels;
1420    List<_TappableLabel> secondaryOuterLabels;
1421    List<_TappableLabel> secondaryInnerLabels;
1422    int selectedDialValue;
1423    switch (widget.mode) {
1424      case _TimePickerMode.hour:
1425        if (widget.use24HourDials) {
1426          selectedDialValue = widget.selectedTime.hour;
1427          primaryOuterLabels = _build24HourOuterRing(theme.textTheme);
1428          secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme);
1429          primaryInnerLabels = _build24HourInnerRing(theme.textTheme);
1430          secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme);
1431        } else {
1432          selectedDialValue = widget.selectedTime.hourOfPeriod;
1433          primaryOuterLabels = _build12HourOuterRing(theme.textTheme);
1434          secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme);
1435        }
1436        break;
1437      case _TimePickerMode.minute:
1438        selectedDialValue = widget.selectedTime.minute;
1439        primaryOuterLabels = _buildMinutes(theme.textTheme);
1440        primaryInnerLabels = null;
1441        secondaryOuterLabels = _buildMinutes(theme.accentTextTheme);
1442        secondaryInnerLabels = null;
1443        break;
1444    }
1445
1446    return GestureDetector(
1447      excludeFromSemantics: true,
1448      onPanStart: _handlePanStart,
1449      onPanUpdate: _handlePanUpdate,
1450      onPanEnd: _handlePanEnd,
1451      onTapUp: _handleTapUp,
1452      child: CustomPaint(
1453        key: const ValueKey<String>('time-picker-dial'),
1454        painter: _DialPainter(
1455          selectedValue: selectedDialValue,
1456          primaryOuterLabels: primaryOuterLabels,
1457          primaryInnerLabels: primaryInnerLabels,
1458          secondaryOuterLabels: secondaryOuterLabels,
1459          secondaryInnerLabels: secondaryInnerLabels,
1460          backgroundColor: backgroundColor,
1461          accentColor: themeData.accentColor,
1462          theta: _theta.value,
1463          activeRing: _activeRing,
1464          textDirection: Directionality.of(context),
1465        ),
1466      ),
1467    );
1468  }
1469}
1470
1471/// A material design time picker designed to appear inside a popup dialog.
1472///
1473/// Pass this widget to [showDialog]. The value returned by [showDialog] is the
1474/// selected [TimeOfDay] if the user taps the "OK" button, or null if the user
1475/// taps the "CANCEL" button. The selected time is reported by calling
1476/// [Navigator.pop].
1477class _TimePickerDialog extends StatefulWidget {
1478  /// Creates a material time picker.
1479  ///
1480  /// [initialTime] must not be null.
1481  const _TimePickerDialog({
1482    Key key,
1483    @required this.initialTime,
1484  }) : assert(initialTime != null),
1485       super(key: key);
1486
1487  /// The time initially selected when the dialog is shown.
1488  final TimeOfDay initialTime;
1489
1490  @override
1491  _TimePickerDialogState createState() => _TimePickerDialogState();
1492}
1493
1494class _TimePickerDialogState extends State<_TimePickerDialog> {
1495  @override
1496  void initState() {
1497    super.initState();
1498    _selectedTime = widget.initialTime;
1499  }
1500
1501  @override
1502  void didChangeDependencies() {
1503    super.didChangeDependencies();
1504    localizations = MaterialLocalizations.of(context);
1505    _announceInitialTimeOnce();
1506    _announceModeOnce();
1507  }
1508
1509  _TimePickerMode _mode = _TimePickerMode.hour;
1510  _TimePickerMode _lastModeAnnounced;
1511
1512  TimeOfDay get selectedTime => _selectedTime;
1513  TimeOfDay _selectedTime;
1514
1515  Timer _vibrateTimer;
1516  MaterialLocalizations localizations;
1517
1518  void _vibrate() {
1519    switch (Theme.of(context).platform) {
1520      case TargetPlatform.android:
1521      case TargetPlatform.fuchsia:
1522        _vibrateTimer?.cancel();
1523        _vibrateTimer = Timer(_kVibrateCommitDelay, () {
1524          HapticFeedback.vibrate();
1525          _vibrateTimer = null;
1526        });
1527        break;
1528      case TargetPlatform.iOS:
1529        break;
1530    }
1531  }
1532
1533  void _handleModeChanged(_TimePickerMode mode) {
1534    _vibrate();
1535    setState(() {
1536      _mode = mode;
1537      _announceModeOnce();
1538    });
1539  }
1540
1541  void _announceModeOnce() {
1542    if (_lastModeAnnounced == _mode) {
1543      // Already announced it.
1544      return;
1545    }
1546
1547    switch (_mode) {
1548      case _TimePickerMode.hour:
1549        _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement);
1550        break;
1551      case _TimePickerMode.minute:
1552        _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement);
1553        break;
1554    }
1555    _lastModeAnnounced = _mode;
1556  }
1557
1558  bool _announcedInitialTime = false;
1559
1560  void _announceInitialTimeOnce() {
1561    if (_announcedInitialTime)
1562      return;
1563
1564    final MediaQueryData media = MediaQuery.of(context);
1565    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
1566    _announceToAccessibility(
1567      context,
1568      localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
1569    );
1570    _announcedInitialTime = true;
1571  }
1572
1573  void _handleTimeChanged(TimeOfDay value) {
1574    _vibrate();
1575    setState(() {
1576      _selectedTime = value;
1577    });
1578  }
1579
1580  void _handleHourSelected() {
1581    setState(() {
1582      _mode = _TimePickerMode.minute;
1583    });
1584  }
1585
1586  void _handleCancel() {
1587    Navigator.pop(context);
1588  }
1589
1590  void _handleOk() {
1591    Navigator.pop(context, _selectedTime);
1592  }
1593
1594  @override
1595  Widget build(BuildContext context) {
1596    assert(debugCheckHasMediaQuery(context));
1597    final MediaQueryData media = MediaQuery.of(context);
1598    final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
1599    final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
1600    final ThemeData theme = Theme.of(context);
1601
1602    final Widget picker = Padding(
1603      padding: const EdgeInsets.all(16.0),
1604      child: AspectRatio(
1605        aspectRatio: 1.0,
1606        child: _Dial(
1607          mode: _mode,
1608          use24HourDials: use24HourDials,
1609          selectedTime: _selectedTime,
1610          onChanged: _handleTimeChanged,
1611          onHourSelected: _handleHourSelected,
1612        ),
1613      ),
1614    );
1615
1616    final Widget actions = ButtonTheme.bar(
1617      child: ButtonBar(
1618        children: <Widget>[
1619          FlatButton(
1620            child: Text(localizations.cancelButtonLabel),
1621            onPressed: _handleCancel,
1622          ),
1623          FlatButton(
1624            child: Text(localizations.okButtonLabel),
1625            onPressed: _handleOk,
1626          ),
1627        ],
1628      ),
1629    );
1630
1631    final Dialog dialog = Dialog(
1632      child: OrientationBuilder(
1633        builder: (BuildContext context, Orientation orientation) {
1634          final Widget header = _TimePickerHeader(
1635            selectedTime: _selectedTime,
1636            mode: _mode,
1637            orientation: orientation,
1638            onModeChanged: _handleModeChanged,
1639            onChanged: _handleTimeChanged,
1640            use24HourDials: use24HourDials,
1641          );
1642
1643          final Widget pickerAndActions = Container(
1644            color: theme.dialogBackgroundColor,
1645            child: Column(
1646              mainAxisSize: MainAxisSize.min,
1647              children: <Widget>[
1648                Expanded(child: picker), // picker grows and shrinks with the available space
1649                actions,
1650              ],
1651            ),
1652          );
1653
1654          double timePickerHeightPortrait;
1655          double timePickerHeightLandscape;
1656          switch (theme.materialTapTargetSize) {
1657            case MaterialTapTargetSize.padded:
1658              timePickerHeightPortrait = _kTimePickerHeightPortrait;
1659              timePickerHeightLandscape = _kTimePickerHeightLandscape;
1660              break;
1661            case MaterialTapTargetSize.shrinkWrap:
1662              timePickerHeightPortrait = _kTimePickerHeightPortraitCollapsed;
1663              timePickerHeightLandscape = _kTimePickerHeightLandscapeCollapsed;
1664              break;
1665          }
1666
1667          assert(orientation != null);
1668          switch (orientation) {
1669            case Orientation.portrait:
1670              return SizedBox(
1671                width: _kTimePickerWidthPortrait,
1672                height: timePickerHeightPortrait,
1673                child: Column(
1674                  mainAxisSize: MainAxisSize.min,
1675                  crossAxisAlignment: CrossAxisAlignment.stretch,
1676                  children: <Widget>[
1677                    header,
1678                    Expanded(
1679                      child: pickerAndActions,
1680                    ),
1681                  ],
1682                ),
1683              );
1684            case Orientation.landscape:
1685              return SizedBox(
1686                width: _kTimePickerWidthLandscape,
1687                height: timePickerHeightLandscape,
1688                child: Row(
1689                  mainAxisSize: MainAxisSize.min,
1690                  crossAxisAlignment: CrossAxisAlignment.stretch,
1691                  children: <Widget>[
1692                    header,
1693                    Flexible(
1694                      child: pickerAndActions,
1695                    ),
1696                  ],
1697                ),
1698              );
1699          }
1700          return null;
1701        }
1702      ),
1703    );
1704
1705    return Theme(
1706      data: theme.copyWith(
1707        dialogBackgroundColor: Colors.transparent,
1708      ),
1709      child: dialog,
1710    );
1711  }
1712
1713  @override
1714  void dispose() {
1715    _vibrateTimer?.cancel();
1716    _vibrateTimer = null;
1717    super.dispose();
1718  }
1719}
1720
1721/// Shows a dialog containing a material design time picker.
1722///
1723/// The returned Future resolves to the time selected by the user when the user
1724/// closes the dialog. If the user cancels the dialog, null is returned.
1725///
1726/// {@tool sample}
1727/// Show a dialog with [initialTime] equal to the current time.
1728///
1729/// ```dart
1730/// Future<TimeOfDay> selectedTime = showTimePicker(
1731///   initialTime: TimeOfDay.now(),
1732///   context: context,
1733/// );
1734/// ```
1735/// {@end-tool}
1736///
1737/// The [context] argument is passed to [showDialog], the documentation for
1738/// which discusses how it is used.
1739///
1740/// The [builder] parameter can be used to wrap the dialog widget
1741/// to add inherited widgets like [Localizations.override],
1742/// [Directionality], or [MediaQuery].
1743///
1744/// {@tool sample}
1745/// Show a dialog with the text direction overridden to be [TextDirection.rtl].
1746///
1747/// ```dart
1748/// Future<TimeOfDay> selectedTimeRTL = showTimePicker(
1749///   context: context,
1750///   initialTime: TimeOfDay.now(),
1751///   builder: (BuildContext context, Widget child) {
1752///     return Directionality(
1753///       textDirection: TextDirection.rtl,
1754///       child: child,
1755///     );
1756///   },
1757/// );
1758/// ```
1759/// {@end-tool}
1760///
1761/// {@tool sample}
1762/// Show a dialog with time unconditionally displayed in 24 hour format.
1763///
1764/// ```dart
1765/// Future<TimeOfDay> selectedTime24Hour = showTimePicker(
1766///   context: context,
1767///   initialTime: TimeOfDay(hour: 10, minute: 47),
1768///   builder: (BuildContext context, Widget child) {
1769///     return MediaQuery(
1770///       data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
1771///       child: child,
1772///     );
1773///   },
1774/// );
1775/// ```
1776/// {@end-tool}
1777///
1778/// See also:
1779///
1780///  * [showDatePicker], which shows a dialog that contains a material design
1781///    date picker.
1782Future<TimeOfDay> showTimePicker({
1783  @required BuildContext context,
1784  @required TimeOfDay initialTime,
1785  TransitionBuilder builder,
1786}) async {
1787  assert(context != null);
1788  assert(initialTime != null);
1789  assert(debugCheckHasMaterialLocalizations(context));
1790
1791  final Widget dialog = _TimePickerDialog(initialTime: initialTime);
1792  return await showDialog<TimeOfDay>(
1793    context: context,
1794    builder: (BuildContext context) {
1795      return builder == null ? dialog : builder(context, dialog);
1796    },
1797  );
1798}
1799
1800void _announceToAccessibility(BuildContext context, String message) {
1801  SemanticsService.announce(message, Directionality.of(context));
1802}
1803