time_picker.dart 54.4 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:async';
6 7
import 'dart:math' as math;

8
import 'package:flutter/foundation.dart';
9
import 'package:flutter/rendering.dart';
10
import 'package:flutter/services.dart';
11 12
import 'package:flutter/widgets.dart';

13
import 'button_bar.dart';
14
import 'button_theme.dart';
15
import 'colors.dart';
16
import 'dialog.dart';
17
import 'feedback.dart';
18
import 'flat_button.dart';
19
import 'material_localizations.dart';
20
import 'theme.dart';
21
import 'time.dart';
22 23
import 'typography.dart';

Adam Barth's avatar
Adam Barth committed
24
const Duration _kDialAnimateDuration = const Duration(milliseconds: 200);
25
const double _kTwoPi = 2 * math.pi;
26
const Duration _kVibrateCommitDelay = const Duration(milliseconds: 100);
Adam Barth's avatar
Adam Barth committed
27

28 29
enum _TimePickerMode { hour, minute }

30 31
const double _kTimePickerHeaderPortraitHeight = 96.0;
const double _kTimePickerHeaderLandscapeWidth = 168.0;
32

33
const double _kTimePickerWidthPortrait = 328.0;
34
const double _kTimePickerWidthLandscape = 512.0;
35

36
const double _kTimePickerHeightPortrait = 484.0;
37 38
const double _kTimePickerHeightLandscape = 304.0;

Yegor's avatar
Yegor committed
39 40 41 42 43
/// The horizontal gap between the day period fragment and the fragment
/// positioned next to it horizontally.
///
/// Normally there's only one horizontal sibling, and it may appear on the left
/// or right depending on the current [TextDirection].
44 45
const double _kPeriodGap = 8.0;

Yegor's avatar
Yegor committed
46 47 48
/// The vertical gap between pieces when laid out vertically (in portrait mode).
const double _kVerticalGap = 8.0;

49 50 51 52 53
enum _TimePickerHeaderId {
  hour,
  colon,
  minute,
  period, // AM/PM picker
Yegor's avatar
Yegor committed
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
  dot,
  hString, // French Canadian "h" literal
}

/// Provides properties for rendering time picker header fragments.
@immutable
class _TimePickerFragmentContext {
  const _TimePickerFragmentContext({
    @required this.headerTextTheme,
    @required this.textDirection,
    @required this.selectedTime,
    @required this.mode,
    @required this.activeColor,
    @required this.activeStyle,
    @required this.inactiveColor,
    @required this.inactiveStyle,
    @required this.onTimeChange,
    @required this.onModeChange,
72
    @required this.targetPlatform,
73
    @required this.use24HourDials,
Yegor's avatar
Yegor committed
74 75 76 77 78 79 80 81 82
  }) : assert(headerTextTheme != null),
       assert(textDirection != null),
       assert(selectedTime != null),
       assert(mode != null),
       assert(activeColor != null),
       assert(activeStyle != null),
       assert(inactiveColor != null),
       assert(inactiveStyle != null),
       assert(onTimeChange != null),
83
       assert(onModeChange != null),
84 85
       assert(targetPlatform != null),
       assert(use24HourDials != null);
Yegor's avatar
Yegor committed
86 87 88 89 90 91 92 93 94 95 96

  final TextTheme headerTextTheme;
  final TextDirection textDirection;
  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
  final Color activeColor;
  final TextStyle activeStyle;
  final Color inactiveColor;
  final TextStyle inactiveStyle;
  final ValueChanged<TimeOfDay> onTimeChange;
  final ValueChanged<_TimePickerMode> onModeChange;
97
  final TargetPlatform targetPlatform;
98
  final bool use24HourDials;
Yegor's avatar
Yegor committed
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
}

/// Contains the [widget] and layout properties of an atom of time information,
/// such as am/pm indicator, hour, minute and string literals appearing in the
/// formatted time string.
class _TimePickerHeaderFragment {
  const _TimePickerHeaderFragment({
    @required this.layoutId,
    @required this.widget,
    this.startMargin: 0.0,
  }) : assert(layoutId != null),
        assert(widget != null),
        assert(startMargin != null);

  /// Identifier used by the custom layout to refer to the widget.
  final _TimePickerHeaderId layoutId;

  /// The widget that renders a piece of time information.
  final Widget widget;

  /// Horizontal distance from the fragment appearing at the start of this
  /// fragment.
  ///
  /// This value contributes to the total horizontal width of all fragments
  /// appearing on the same line, unless it is the first fragment on the line,
  /// in which case this value is ignored.
  final double startMargin;
}

/// An unbreakable part of the time picker header.
///
/// When the picker is laid out vertically, [fragments] of the piece are laid
/// out on the same line, with each piece getting its own line.
class _TimePickerHeaderPiece {
  /// Creates a time picker header piece.
  ///
  /// All arguments must be non-null. If the piece does not contain a pivot
  /// fragment, use the value -1 as a convention.
  const _TimePickerHeaderPiece(this.pivotIndex, this.fragments, { this.bottomMargin: 0.0 })
      : assert(pivotIndex != null),
        assert(fragments != null),
        assert(bottomMargin != null);

  /// Index into the [fragments] list, pointing at the fragment that's centered
  /// horizontally.
  final int pivotIndex;

  /// Fragments this piece is made of.
  final List<_TimePickerHeaderFragment> fragments;

  /// Vertical distance between this piece and the next piece.
  ///
  /// This property applies only when the header is laid out vertically.
  final double bottomMargin;
}

/// Describes how the time picker header must be formatted.
///
/// A [_TimePickerHeaderFormat] is made of multiple [_TimePickerHeaderPiece]s.
/// A piece is made of multiple [_TimePickerHeaderFragment]s. A fragment has a
/// widget used to render some time information and contains some layout
/// properties.
///
/// ## Layout rules
///
/// Pieces are laid out such that all fragments inside the same piece are laid
/// out horizontally. Pieces are laid out horizontally if portrait orientation,
/// and vertically in landscape orientation.
///
168
/// One of the pieces is identified as a _centerpiece_. It is a piece that is
Yegor's avatar
Yegor committed
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192
/// positioned in the center of the header, with all other pieces positioned
/// to the left or right of it.
class _TimePickerHeaderFormat {
  const _TimePickerHeaderFormat(this.centrepieceIndex, this.pieces)
      : assert(centrepieceIndex != null),
        assert(pieces != null);

  /// Index into the [pieces] list pointing at the piece that contains the
  /// pivot fragment.
  final int centrepieceIndex;

  /// Pieces that constitute a time picker header.
  final List<_TimePickerHeaderPiece> pieces;
}

/// Displays the am/pm fragment and provides controls for switching between am
/// and pm.
class _DayPeriodControl extends StatelessWidget {
  const _DayPeriodControl({
    @required this.fragmentContext,
  });

  final _TimePickerFragmentContext fragmentContext;

193
  void _togglePeriod() {
194
    final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
    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();
Yegor's avatar
Yegor committed
217 218 219 220 221 222 223 224 225
  }

  @override
  Widget build(BuildContext context) {
    final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context);
    final TextTheme headerTextTheme = fragmentContext.headerTextTheme;
    final TimeOfDay selectedTime = fragmentContext.selectedTime;
    final Color activeColor = fragmentContext.activeColor;
    final Color inactiveColor = fragmentContext.inactiveColor;
226
    final bool amSelected = selectedTime.period == DayPeriod.am;
Yegor's avatar
Yegor committed
227
    final TextStyle amStyle = headerTextTheme.subhead.copyWith(
228
      color: amSelected ? activeColor: inactiveColor
Yegor's avatar
Yegor committed
229 230
    );
    final TextStyle pmStyle = headerTextTheme.subhead.copyWith(
231
      color: !amSelected ? activeColor: inactiveColor
Yegor's avatar
Yegor committed
232 233
    );

234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
    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),
          ),
        ),
251
        const SizedBox(width: 0.0, height: 4.0), // Vertical spacer
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266
        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),
          ),
        ),
      ],
Yegor's avatar
Yegor committed
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
    );
  }
}

/// Displays the hour fragment.
///
/// When tapped changes time picker dial mode to [_TimePickerMode.hour].
class _HourControl extends StatelessWidget {
  const _HourControl({
    @required this.fragmentContext,
  });

  final _TimePickerFragmentContext fragmentContext;

  @override
  Widget build(BuildContext context) {
283
    assert(debugCheckHasMediaQuery(context));
284
    final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
285
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
Yegor's avatar
Yegor committed
286 287 288
    final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour
        ? fragmentContext.activeStyle
        : fragmentContext.inactiveStyle;
289 290
    final String formattedHour = localizations.formatHour(
      fragmentContext.selectedTime,
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
      alwaysUse24HourFormat: alwaysUse24HourFormat,
    );

    TimeOfDay hoursFromSelected(int hoursToAdd) {
      if (fragmentContext.use24HourDials) {
        final int selectedHour = fragmentContext.selectedTime.hour;
        return fragmentContext.selectedTime.replacing(
          hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay,
        );
      } else {
        // Cycle 1 through 12 without changing day period.
        final int periodOffset = fragmentContext.selectedTime.periodOffset;
        final int hours = fragmentContext.selectedTime.hourOfPeriod;
        return fragmentContext.selectedTime.replacing(
          hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod,
        );
      }
    }

    final TimeOfDay nextHour = hoursFromSelected(1);
    final String formattedNextHour = localizations.formatHour(
      nextHour,
      alwaysUse24HourFormat: alwaysUse24HourFormat,
    );
    final TimeOfDay previousHour = hoursFromSelected(-1);
    final String formattedPreviousHour = localizations.formatHour(
      previousHour,
      alwaysUse24HourFormat: alwaysUse24HourFormat,
319
    );
Yegor's avatar
Yegor committed
320 321 322

    return new GestureDetector(
      onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
323 324
      child: new Semantics(
        hint: localizations.timePickerHourModeAnnouncement,
325 326 327 328 329 330 331 332 333 334 335 336
        value: formattedHour,
        increasedValue: formattedNextHour,
        onIncrease: () {
          fragmentContext.onTimeChange(nextHour);
        },
        decreasedValue: formattedPreviousHour,
        onDecrease: () {
          fragmentContext.onTimeChange(previousHour);
        },
        child: new ExcludeSemantics(
          child: new Text(formattedHour, style: hourStyle),
        ),
337
      ),
Yegor's avatar
Yegor committed
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
    );
  }
}

/// A passive fragment showing a string value.
class _StringFragment extends StatelessWidget {
  const _StringFragment({
    @required this.fragmentContext,
    @required this.value,
  });

  final _TimePickerFragmentContext fragmentContext;
  final String value;

  @override
  Widget build(BuildContext context) {
354 355 356
    return new ExcludeSemantics(
      child: new Text(value, style: fragmentContext.inactiveStyle),
    );
Yegor's avatar
Yegor committed
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
  }
}

/// Displays the minute fragment.
///
/// When tapped changes time picker dial mode to [_TimePickerMode.minute].
class _MinuteControl extends StatelessWidget {
  const _MinuteControl({
    @required this.fragmentContext,
  });

  final _TimePickerFragmentContext fragmentContext;

  @override
  Widget build(BuildContext context) {
372
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
Yegor's avatar
Yegor committed
373 374 375
    final TextStyle minuteStyle = fragmentContext.mode == _TimePickerMode.minute
        ? fragmentContext.activeStyle
        : fragmentContext.inactiveStyle;
376 377 378 379 380 381 382 383 384 385 386
    final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime);

    final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing(
      minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour,
    );
    final String formattedNextMinute = localizations.formatMinute(nextMinute);

    final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing(
      minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour,
    );
    final String formattedPreviousMinute = localizations.formatMinute(previousMinute);
Yegor's avatar
Yegor committed
387 388 389

    return new GestureDetector(
      onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
390 391
      child: new Semantics(
        hint: localizations.timePickerMinuteModeAnnouncement,
392 393 394 395 396 397 398 399 400 401 402 403
        value: formattedMinute,
        increasedValue: formattedNextMinute,
        onIncrease: () {
          fragmentContext.onTimeChange(nextMinute);
        },
        decreasedValue: formattedPreviousMinute,
        onDecrease: () {
          fragmentContext.onTimeChange(previousMinute);
        },
        child: new ExcludeSemantics(
          child: new Text(formattedMinute, style: minuteStyle),
        ),
404
      ),
Yegor's avatar
Yegor committed
405 406 407 408 409
    );
  }
}

/// Provides time picker header layout configuration for the given
410 411
/// [timeOfDayFormat] passing [context] to each widget in the
/// configuration.
Yegor's avatar
Yegor committed
412
///
413
/// The [timeOfDayFormat] and [context] arguments must not be null.
Yegor's avatar
Yegor committed
414 415
_TimePickerHeaderFormat _buildHeaderFormat(TimeOfDayFormat timeOfDayFormat, _TimePickerFragmentContext context) {
  // Creates an hour fragment.
416
  _TimePickerHeaderFragment hour() {
Yegor's avatar
Yegor committed
417 418
    return new _TimePickerHeaderFragment(
      layoutId: _TimePickerHeaderId.hour,
419
      widget: new _HourControl(fragmentContext: context),
Yegor's avatar
Yegor committed
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452
      startMargin: _kPeriodGap,
    );
  }

  // Creates a minute fragment.
  _TimePickerHeaderFragment minute() {
    return new _TimePickerHeaderFragment(
      layoutId: _TimePickerHeaderId.minute,
      widget: new _MinuteControl(fragmentContext: context),
    );
  }

  // Creates a string fragment.
  _TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) {
    return new _TimePickerHeaderFragment(
      layoutId: layoutId,
      widget: new _StringFragment(
        fragmentContext: context,
        value: value,
      ),
    );
  }

  // Creates an am/pm fragment.
  _TimePickerHeaderFragment dayPeriod() {
    return new _TimePickerHeaderFragment(
      layoutId: _TimePickerHeaderId.period,
      widget: new _DayPeriodControl(fragmentContext: context),
      startMargin: _kPeriodGap,
    );
  }

  // Convenience function for creating a time header format with up to two pieces.
453
  _TimePickerHeaderFormat format(_TimePickerHeaderPiece piece1,
Yegor's avatar
Yegor committed
454 455 456 457 458 459 460 461 462 463 464 465 466 467
      [ _TimePickerHeaderPiece piece2 ]) {
    final List<_TimePickerHeaderPiece> pieces = <_TimePickerHeaderPiece>[];
    switch (context.textDirection) {
      case TextDirection.ltr:
        pieces.add(piece1);
        if (piece2 != null)
          pieces.add(piece2);
        break;
      case TextDirection.rtl:
        if (piece2 != null)
          pieces.add(piece2);
        pieces.add(piece1);
        break;
    }
468 469 470 471 472 473 474
    int centrepieceIndex;
    for (int i = 0; i < pieces.length; i += 1) {
      if (pieces[i].pivotIndex >= 0) {
        centrepieceIndex = i;
      }
    }
    assert(centrepieceIndex != null);
Yegor's avatar
Yegor committed
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494
    return new _TimePickerHeaderFormat(centrepieceIndex, pieces);
  }

  // Convenience function for creating a time header piece with up to three fragments.
  _TimePickerHeaderPiece piece({ int pivotIndex: -1, double bottomMargin: 0.0,
      _TimePickerHeaderFragment fragment1, _TimePickerHeaderFragment fragment2, _TimePickerHeaderFragment fragment3 }) {
    final List<_TimePickerHeaderFragment> fragments = <_TimePickerHeaderFragment>[fragment1];
    if (fragment2 != null) {
      fragments.add(fragment2);
      if (fragment3 != null)
        fragments.add(fragment3);
    }
    return new _TimePickerHeaderPiece(pivotIndex, fragments, bottomMargin: bottomMargin);
  }

  switch (timeOfDayFormat) {
    case TimeOfDayFormat.h_colon_mm_space_a:
      return format(
        piece(
          pivotIndex: 1,
495
          fragment1: hour(),
Yegor's avatar
Yegor committed
496 497 498 499 500 501 502 503 504
          fragment2: string(_TimePickerHeaderId.colon, ':'),
          fragment3: minute(),
        ),
        piece(
          bottomMargin: _kVerticalGap,
          fragment1: dayPeriod(),
        ),
      );
    case TimeOfDayFormat.H_colon_mm:
505
      return format(piece(
Yegor's avatar
Yegor committed
506
        pivotIndex: 1,
507
        fragment1: hour(),
Yegor's avatar
Yegor committed
508 509 510 511
        fragment2: string(_TimePickerHeaderId.colon, ':'),
        fragment3: minute(),
      ));
    case TimeOfDayFormat.HH_dot_mm:
512
      return format(piece(
Yegor's avatar
Yegor committed
513
        pivotIndex: 1,
514
        fragment1: hour(),
Yegor's avatar
Yegor committed
515 516 517 518 519 520 521 522 523 524 525
        fragment2: string(_TimePickerHeaderId.dot, '.'),
        fragment3: minute(),
      ));
    case TimeOfDayFormat.a_space_h_colon_mm:
      return format(
        piece(
          bottomMargin: _kVerticalGap,
          fragment1: dayPeriod(),
        ),
        piece(
          pivotIndex: 1,
526
          fragment1: hour(),
Yegor's avatar
Yegor committed
527 528 529 530 531
          fragment2: string(_TimePickerHeaderId.colon, ':'),
          fragment3: minute(),
        ),
      );
    case TimeOfDayFormat.frenchCanadian:
532
      return format(piece(
Yegor's avatar
Yegor committed
533
        pivotIndex: 1,
534
        fragment1: hour(),
Yegor's avatar
Yegor committed
535 536 537 538
        fragment2: string(_TimePickerHeaderId.hString, 'h'),
        fragment3: minute(),
      ));
    case TimeOfDayFormat.HH_colon_mm:
539
      return format(piece(
Yegor's avatar
Yegor committed
540
        pivotIndex: 1,
541
        fragment1: hour(),
Yegor's avatar
Yegor committed
542 543 544 545 546 547
        fragment2: string(_TimePickerHeaderId.colon, ':'),
        fragment3: minute(),
      ));
  }

  return null;
548 549 550
}

class _TimePickerHeaderLayout extends MultiChildLayoutDelegate {
Yegor's avatar
Yegor committed
551 552 553
  _TimePickerHeaderLayout(this.orientation, this.format)
    : assert(orientation != null),
      assert(format != null);
554 555

  final Orientation orientation;
Yegor's avatar
Yegor committed
556
  final _TimePickerHeaderFormat format;
557 558 559 560 561 562 563

  @override
  void performLayout(Size size) {
    final BoxConstraints constraints = new BoxConstraints.loose(size);

    switch (orientation) {
      case Orientation.portrait:
Yegor's avatar
Yegor committed
564 565 566 567 568 569 570
        _layoutHorizontally(size, constraints);
        break;
      case Orientation.landscape:
        _layoutVertically(size, constraints);
        break;
    }
  }
571

Yegor's avatar
Yegor committed
572 573 574 575 576 577 578 579 580 581
  void _layoutHorizontally(Size size, BoxConstraints constraints) {
    final List<_TimePickerHeaderFragment> fragmentsFlattened = <_TimePickerHeaderFragment>[];
    final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{};
    int pivotIndex = 0;
    for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
      final _TimePickerHeaderPiece piece = format.pieces[pieceIndex];
      for (final _TimePickerHeaderFragment fragment in piece.fragments) {
        childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints);
        fragmentsFlattened.add(fragment);
      }
582

Yegor's avatar
Yegor committed
583 584 585 586 587
      if (pieceIndex == format.centrepieceIndex)
        pivotIndex += format.pieces[format.centrepieceIndex].pivotIndex;
      else if (pieceIndex < format.centrepieceIndex)
        pivotIndex += piece.fragments.length;
    }
588

Yegor's avatar
Yegor committed
589 590
    _positionPivoted(size.width, size.height / 2.0, childSizes, fragmentsFlattened, pivotIndex);
  }
591

Yegor's avatar
Yegor committed
592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608
  void _layoutVertically(Size size, BoxConstraints constraints) {
    final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{};
    final List<double> pieceHeights = <double>[];
    double height = 0.0;
    double margin = 0.0;
    for (final _TimePickerHeaderPiece piece in format.pieces) {
      double pieceHeight = 0.0;
      for (final _TimePickerHeaderFragment fragment in piece.fragments) {
        final Size childSize = childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints);
        pieceHeight = math.max(pieceHeight, childSize.height);
      }
      pieceHeights.add(pieceHeight);
      height += pieceHeight + margin;
      // Delay application of margin until next piece because margin of the
      // bottom-most piece should not contribute to the size.
      margin = piece.bottomMargin;
    }
609

Yegor's avatar
Yegor committed
610 611 612 613 614 615 616
    final _TimePickerHeaderPiece centrepiece = format.pieces[format.centrepieceIndex];
    double y = (size.height - height) / 2.0;
    for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
      if (pieceIndex != format.centrepieceIndex)
        _positionPiece(size.width, y, childSizes, format.pieces[pieceIndex].fragments);
      else
        _positionPivoted(size.width, y, childSizes, centrepiece.fragments, centrepiece.pivotIndex);
617

Yegor's avatar
Yegor committed
618 619 620
      y += pieceHeights[pieceIndex] + format.pieces[pieceIndex].bottomMargin;
    }
  }
621

Yegor's avatar
Yegor committed
622 623 624 625 626
  void _positionPivoted(double width, double y, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments, int pivotIndex) {
    double tailWidth = childSizes[fragments[pivotIndex].layoutId].width / 2.0;
    for (_TimePickerHeaderFragment fragment in fragments.skip(pivotIndex + 1)) {
      tailWidth += childSizes[fragment.layoutId].width + fragment.startMargin;
    }
627

Yegor's avatar
Yegor committed
628 629 630 631 632 633 634 635 636 637
    double x = width / 2.0 + tailWidth;
    x = math.min(x, width);
    for (int i = fragments.length - 1; i >= 0; i -= 1) {
      final _TimePickerHeaderFragment fragment = fragments[i];
      final Size childSize = childSizes[fragment.layoutId];
      x -= childSize.width;
      positionChild(fragment.layoutId, new Offset(x, y - childSize.height / 2.0));
      x -= fragment.startMargin;
    }
  }
638

Yegor's avatar
Yegor committed
639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655
  void _positionPiece(double width, double centeredAroundY, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments) {
    double pieceWidth = 0.0;
    double nextMargin = 0.0;
    for (_TimePickerHeaderFragment fragment in fragments) {
      final Size childSize = childSizes[fragment.layoutId];
      pieceWidth += childSize.width + nextMargin;
      // Delay application of margin until next element because margin of the
      // left-most fragment should not contribute to the size.
      nextMargin = fragment.startMargin;
    }
    double x = (width + pieceWidth) / 2.0;
    for (int i = fragments.length - 1; i >= 0; i -= 1) {
      final _TimePickerHeaderFragment fragment = fragments[i];
      final Size childSize = childSizes[fragment.layoutId];
      x -= childSize.width;
      positionChild(fragment.layoutId, new Offset(x, centeredAroundY - childSize.height / 2.0));
      x -= fragment.startMargin;
656 657 658 659
    }
  }

  @override
Yegor's avatar
Yegor committed
660
  bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation || format != oldDelegate.format;
661 662
}

663
class _TimePickerHeader extends StatelessWidget {
664
  const _TimePickerHeader({
665 666
    @required this.selectedTime,
    @required this.mode,
667
    @required this.orientation,
668
    @required this.onModeChanged,
669
    @required this.onChanged,
670
    @required this.use24HourDials,
671 672
  }) : assert(selectedTime != null),
       assert(mode != null),
673 674
       assert(orientation != null),
       assert(use24HourDials != null);
675

676 677
  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
678
  final Orientation orientation;
679
  final ValueChanged<_TimePickerMode> onModeChanged;
Adam Barth's avatar
Adam Barth committed
680
  final ValueChanged<TimeOfDay> onChanged;
681
  final bool use24HourDials;
682 683 684 685 686 687

  void _handleChangeMode(_TimePickerMode value) {
    if (value != mode)
      onModeChanged(value);
  }

688 689 690 691 692 693 694 695 696 697 698 699 700 701
  TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) {
    // These font sizes aren't listed in the spec explicitly. I worked them out
    // by measuring the text using a screen ruler and comparing them to the
    // screen shots of the time picker in the spec.
    assert(orientation != null);
    switch (orientation) {
      case Orientation.portrait:
        return headerTextTheme.display3.copyWith(fontSize: 60.0);
      case Orientation.landscape:
        return headerTextTheme.display2.copyWith(fontSize: 50.0);
    }
    return null;
  }

702
  @override
703
  Widget build(BuildContext context) {
704
    assert(debugCheckHasMediaQuery(context));
705
    final ThemeData themeData = Theme.of(context);
706 707 708
    final MediaQueryData media = MediaQuery.of(context);
    final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context)
        .timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
Yegor's avatar
Yegor committed
709 710 711 712 713 714 715 716 717 718

    EdgeInsets padding;
    double height;
    double width;

    assert(orientation != null);
    switch (orientation) {
      case Orientation.portrait:
        height = _kTimePickerHeaderPortraitHeight;
        padding = const EdgeInsets.symmetric(horizontal: 24.0);
719
        break;
Yegor's avatar
Yegor committed
720 721 722
      case Orientation.landscape:
        width = _kTimePickerHeaderLandscapeWidth;
        padding = const EdgeInsets.symmetric(horizontal: 16.0);
723 724
        break;
    }
725 726 727

    Color backgroundColor;
    switch (themeData.brightness) {
728
      case Brightness.light:
729 730
        backgroundColor = themeData.primaryColor;
        break;
731
      case Brightness.dark:
732 733 734 735
        backgroundColor = themeData.backgroundColor;
        break;
    }

Yegor's avatar
Yegor committed
736 737 738 739 740 741
    Color activeColor;
    Color inactiveColor;
    switch (themeData.primaryColorBrightness) {
      case Brightness.light:
        activeColor = Colors.black87;
        inactiveColor = Colors.black54;
742
        break;
Yegor's avatar
Yegor committed
743 744 745
      case Brightness.dark:
        activeColor = Colors.white;
        inactiveColor = Colors.white70;
746
        break;
747
    }
748

Yegor's avatar
Yegor committed
749 750 751 752 753 754 755 756 757 758 759 760 761
    final TextTheme headerTextTheme = themeData.primaryTextTheme;
    final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme);
    final _TimePickerFragmentContext fragmentContext = new _TimePickerFragmentContext(
      headerTextTheme: headerTextTheme,
      textDirection: Directionality.of(context),
      selectedTime: selectedTime,
      mode: mode,
      activeColor: activeColor,
      activeStyle: baseHeaderStyle.copyWith(color: activeColor),
      inactiveColor: inactiveColor,
      inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor),
      onTimeChange: onChanged,
      onModeChange: _handleChangeMode,
762
      targetPlatform: themeData.platform,
763
      use24HourDials: use24HourDials,
Yegor's avatar
Yegor committed
764 765 766 767
    );

    final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext);

768 769 770 771
    return new Container(
      width: width,
      height: height,
      padding: padding,
772
      color: backgroundColor,
773
      child: new CustomMultiChildLayout(
Yegor's avatar
Yegor committed
774 775 776 777 778 779 780 781 782 783
        delegate: new _TimePickerHeaderLayout(orientation, format),
        children: format.pieces
          .expand<_TimePickerHeaderFragment>((_TimePickerHeaderPiece piece) => piece.fragments)
          .map<Widget>((_TimePickerHeaderFragment fragment) {
            return new LayoutId(
              id: fragment.layoutId,
              child: fragment.widget,
            );
          })
          .toList(),
784 785
      )
    );
786 787
  }
}
788

Yegor's avatar
Yegor committed
789 790 791 792 793
enum _DialRing {
  outer,
  inner,
}

794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810
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;
}

811 812
class _DialPainter extends CustomPainter {
  const _DialPainter({
Yegor's avatar
Yegor committed
813 814 815 816 817 818 819 820
    @required this.primaryOuterLabels,
    @required this.primaryInnerLabels,
    @required this.secondaryOuterLabels,
    @required this.secondaryInnerLabels,
    @required this.backgroundColor,
    @required this.accentColor,
    @required this.theta,
    @required this.activeRing,
821 822
    @required this.textDirection,
    @required this.selectedValue,
823 824
  });

825 826 827 828
  final List<_TappableLabel> primaryOuterLabels;
  final List<_TappableLabel> primaryInnerLabels;
  final List<_TappableLabel> secondaryOuterLabels;
  final List<_TappableLabel> secondaryInnerLabels;
829 830
  final Color backgroundColor;
  final Color accentColor;
831
  final double theta;
Yegor's avatar
Yegor committed
832
  final _DialRing activeRing;
833 834
  final TextDirection textDirection;
  final int selectedValue;
835

836
  @override
837
  void paint(Canvas canvas, Size size) {
838 839
    final double radius = size.shortestSide / 2.0;
    final Offset center = new Offset(size.width / 2.0, size.height / 2.0);
840
    final Offset centerPoint = center;
841
    canvas.drawCircle(centerPoint, radius, new Paint()..color = backgroundColor);
842 843

    const double labelPadding = 24.0;
Yegor's avatar
Yegor committed
844 845 846 847 848 849 850 851 852 853 854 855
    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;
      }
856 857 858 859
      return center + new Offset(labelRadius * math.cos(theta),
                                 -labelRadius * math.sin(theta));
    }

860
    void paintLabels(List<_TappableLabel> labels, _DialRing ring) {
Yegor's avatar
Yegor committed
861 862
      if (labels == null)
        return;
863
      final double labelThetaIncrement = -_kTwoPi / labels.length;
864
      double labelTheta = math.pi / 2.0;
865

866 867 868 869
      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);
870 871
        labelTheta += labelThetaIncrement;
      }
872
    }
873

Yegor's avatar
Yegor committed
874 875
    paintLabels(primaryOuterLabels, _DialRing.outer);
    paintLabels(primaryInnerLabels, _DialRing.inner);
876 877 878

    final Paint selectorPaint = new Paint()
      ..color = accentColor;
Yegor's avatar
Yegor committed
879
    final Offset focusedPoint = getOffsetForTheta(theta, activeRing);
880
    const double focusedRadius = labelPadding - 4.0;
881 882 883 884 885 886 887 888 889
    canvas.drawCircle(centerPoint, 4.0, selectorPaint);
    canvas.drawCircle(focusedPoint, focusedRadius, selectorPaint);
    selectorPaint.strokeWidth = 2.0;
    canvas.drawLine(centerPoint, focusedPoint, selectorPaint);

    final Rect focusedRect = new Rect.fromCircle(
      center: focusedPoint, radius: focusedRadius
    );
    canvas
890
      ..save()
891
      ..clipPath(new Path()..addOval(focusedRect));
Yegor's avatar
Yegor committed
892 893
    paintLabels(secondaryOuterLabels, _DialRing.outer);
    paintLabels(secondaryInnerLabels, _DialRing.inner);
894
    canvas.restore();
895 896
  }

897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933
  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;
934
      double labelTheta = math.pi / 2.0;
935 936 937 938 939 940 941 942 943 944 945 946 947 948 949

      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,
950
            value: labelPainter.text.text,
951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970
            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;
  }

971
  @override
972
  bool shouldRepaint(_DialPainter oldPainter) {
Yegor's avatar
Yegor committed
973 974 975 976
    return oldPainter.primaryOuterLabels != primaryOuterLabels
        || oldPainter.primaryInnerLabels != primaryInnerLabels
        || oldPainter.secondaryOuterLabels != secondaryOuterLabels
        || oldPainter.secondaryInnerLabels != secondaryInnerLabels
977 978
        || oldPainter.backgroundColor != backgroundColor
        || oldPainter.accentColor != accentColor
Yegor's avatar
Yegor committed
979 980
        || oldPainter.theta != theta
        || oldPainter.activeRing != activeRing;
981 982 983
  }
}

984
class _Dial extends StatefulWidget {
985
  const _Dial({
986 987
    @required this.selectedTime,
    @required this.mode,
988
    @required this.use24HourDials,
989
    @required this.onChanged
990
  }) : assert(selectedTime != null);
991 992 993

  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
994
  final bool use24HourDials;
995 996
  final ValueChanged<TimeOfDay> onChanged;

997
  @override
998 999 1000
  _DialState createState() => new _DialState();
}

1001
class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
1002
  @override
1003 1004
  void initState() {
    super.initState();
1005
    _updateDialRingFromWidget();
1006 1007 1008 1009
    _thetaController = new AnimationController(
      duration: _kDialAnimateDuration,
      vsync: this,
    );
1010
    _thetaTween = new Tween<double>(begin: _getThetaForTime(widget.selectedTime));
1011 1012
    _theta = _thetaTween.animate(new CurvedAnimation(
      parent: _thetaController,
1013
      curve: Curves.fastOutSlowIn
1014
    ))..addListener(() => setState(() { }));
1015 1016
  }

1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029
  ThemeData themeData;
  MaterialLocalizations localizations;
  MediaQueryData media;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    assert(debugCheckHasMediaQuery(context));
    themeData = Theme.of(context);
    localizations = MaterialLocalizations.of(context);
    media = MediaQuery.of(context);
  }

1030
  @override
1031
  void didUpdateWidget(_Dial oldWidget) {
1032
    super.didUpdateWidget(oldWidget);
1033 1034
    _updateDialRingFromWidget();
    if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) {
Yegor's avatar
Yegor committed
1035 1036 1037
      if (!_dragging)
        _animateTo(_getThetaForTime(widget.selectedTime));
    }
1038 1039 1040 1041 1042 1043 1044
  }

  void _updateDialRingFromWidget() {
    if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
      _activeRing = widget.selectedTime.hour >= 1 && widget.selectedTime.hour <= 12
          ? _DialRing.inner
          : _DialRing.outer;
Yegor's avatar
Yegor committed
1045 1046 1047
    } else {
      _activeRing = _DialRing.outer;
    }
Adam Barth's avatar
Adam Barth committed
1048 1049
  }

1050 1051 1052 1053 1054 1055
  @override
  void dispose() {
    _thetaController.dispose();
    super.dispose();
  }

1056
  Tween<double> _thetaTween;
1057
  Animation<double> _theta;
1058
  AnimationController _thetaController;
Adam Barth's avatar
Adam Barth committed
1059 1060 1061 1062 1063 1064 1065
  bool _dragging = false;

  static double _nearest(double target, double a, double b) {
    return ((target - a).abs() < (target - b).abs()) ? a : b;
  }

  void _animateTo(double targetTheta) {
1066
    final double currentTheta = _theta.value;
Adam Barth's avatar
Adam Barth committed
1067 1068
    double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi);
    beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi);
1069 1070 1071 1072 1073 1074
    _thetaTween
      ..begin = beginTheta
      ..end = targetTheta;
    _thetaController
      ..value = 0.0
      ..forward();
1075 1076 1077
  }

  double _getThetaForTime(TimeOfDay time) {
1078 1079 1080
    final double fraction = widget.mode == _TimePickerMode.hour
      ? (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod
      : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
1081
    return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi;
1082 1083 1084
  }

  TimeOfDay _getTimeForTheta(double theta) {
1085
    final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
1086
    if (widget.mode == _TimePickerMode.hour) {
1087
      int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod;
1088
      if (widget.use24HourDials) {
Yegor's avatar
Yegor committed
1089 1090
        if (_activeRing == _DialRing.outer) {
          if (newHour != 0)
1091
            newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
Yegor's avatar
Yegor committed
1092
        } else if (newHour == 0) {
1093
          newHour = TimeOfDay.hoursPerPeriod;
Yegor's avatar
Yegor committed
1094 1095 1096 1097 1098
        }
      } else {
        newHour = newHour + widget.selectedTime.periodOffset;
      }
      return widget.selectedTime.replacing(hour: newHour);
1099
    } else {
1100
      return widget.selectedTime.replacing(
1101
        minute: (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour
1102 1103 1104 1105
      );
    }
  }

1106
  TimeOfDay _notifyOnChangedIfNeeded() {
1107
    final TimeOfDay current = _getTimeForTheta(_theta.value);
1108 1109
    if (widget.onChanged == null)
      return current;
1110 1111
    if (current != widget.selectedTime)
      widget.onChanged(current);
1112
    return current;
1113 1114 1115 1116
  }

  void _updateThetaForPan() {
    setState(() {
Hans Muller's avatar
Hans Muller committed
1117
      final Offset offset = _position - _center;
1118
      final double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi;
1119
      _thetaTween
Hans Muller's avatar
Hans Muller committed
1120 1121
        ..begin = angle
        ..end = angle; // The controller doesn't animate during the pan gesture.
Yegor's avatar
Yegor committed
1122 1123
      final RenderBox box = context.findRenderObject();
      final double radius = box.size.shortestSide / 2.0;
1124
      if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
Yegor's avatar
Yegor committed
1125 1126 1127 1128 1129
        if (offset.distance * 1.5 < radius)
          _activeRing = _DialRing.inner;
        else
          _activeRing = _DialRing.outer;
      }
1130 1131 1132
    });
  }

1133 1134
  Offset _position;
  Offset _center;
Yegor's avatar
Yegor committed
1135
  _DialRing _activeRing = _DialRing.outer;
1136

1137
  void _handlePanStart(DragStartDetails details) {
Adam Barth's avatar
Adam Barth committed
1138 1139
    assert(!_dragging);
    _dragging = true;
1140
    final RenderBox box = context.findRenderObject();
1141
    _position = box.globalToLocal(details.globalPosition);
1142
    _center = box.size.center(Offset.zero);
1143 1144 1145 1146
    _updateThetaForPan();
    _notifyOnChangedIfNeeded();
  }

1147 1148
  void _handlePanUpdate(DragUpdateDetails details) {
    _position += details.delta;
1149 1150 1151 1152
    _updateThetaForPan();
    _notifyOnChangedIfNeeded();
  }

1153
  void _handlePanEnd(DragEndDetails details) {
Adam Barth's avatar
Adam Barth committed
1154 1155
    assert(_dragging);
    _dragging = false;
1156 1157
    _position = null;
    _center = null;
1158
    _animateTo(_getThetaForTime(widget.selectedTime));
1159 1160
  }

1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217
  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();
  }

1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247
  static const List<TimeOfDay> _amHours = const <TimeOfDay>[
    const TimeOfDay(hour: 12, minute: 0),
    const TimeOfDay(hour: 1, minute: 0),
    const TimeOfDay(hour: 2, minute: 0),
    const TimeOfDay(hour: 3, minute: 0),
    const TimeOfDay(hour: 4, minute: 0),
    const TimeOfDay(hour: 5, minute: 0),
    const TimeOfDay(hour: 6, minute: 0),
    const TimeOfDay(hour: 7, minute: 0),
    const TimeOfDay(hour: 8, minute: 0),
    const TimeOfDay(hour: 9, minute: 0),
    const TimeOfDay(hour: 10, minute: 0),
    const TimeOfDay(hour: 11, minute: 0),
  ];

  static const List<TimeOfDay> _pmHours = const <TimeOfDay>[
    const TimeOfDay(hour: 0, minute: 0),
    const TimeOfDay(hour: 13, minute: 0),
    const TimeOfDay(hour: 14, minute: 0),
    const TimeOfDay(hour: 15, minute: 0),
    const TimeOfDay(hour: 16, minute: 0),
    const TimeOfDay(hour: 17, minute: 0),
    const TimeOfDay(hour: 18, minute: 0),
    const TimeOfDay(hour: 19, minute: 0),
    const TimeOfDay(hour: 20, minute: 0),
    const TimeOfDay(hour: 21, minute: 0),
    const TimeOfDay(hour: 22, minute: 0),
    const TimeOfDay(hour: 23, minute: 0),
  ];

1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259
  _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,
    );
1260 1261
  }

1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274
  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;
1275 1276
  }

1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304
  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<_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;
1305 1306
  }

1307
  List<_TappableLabel> _buildMinutes(TextTheme textTheme) {
1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322
    const List<TimeOfDay> _minuteMarkerValues = const <TimeOfDay>[
      const TimeOfDay(hour: 0, minute: 0),
      const TimeOfDay(hour: 0, minute: 5),
      const TimeOfDay(hour: 0, minute: 10),
      const TimeOfDay(hour: 0, minute: 15),
      const TimeOfDay(hour: 0, minute: 20),
      const TimeOfDay(hour: 0, minute: 25),
      const TimeOfDay(hour: 0, minute: 30),
      const TimeOfDay(hour: 0, minute: 35),
      const TimeOfDay(hour: 0, minute: 40),
      const TimeOfDay(hour: 0, minute: 45),
      const TimeOfDay(hour: 0, minute: 50),
      const TimeOfDay(hour: 0, minute: 55),
    ];

1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334
    final List<_TappableLabel> labels = <_TappableLabel>[];
    for (TimeOfDay timeOfDay in _minuteMarkerValues) {
      labels.add(_buildTappableLabel(
        textTheme,
        timeOfDay.minute,
        localizations.formatMinute(timeOfDay),
        () {
          _selectMinute(timeOfDay.minute);
        },
      ));
    }
    return labels;
1335 1336
  }

1337
  @override
1338
  Widget build(BuildContext context) {
1339 1340
    Color backgroundColor;
    switch (themeData.brightness) {
1341
      case Brightness.light:
1342 1343
        backgroundColor = Colors.grey[200];
        break;
1344
      case Brightness.dark:
1345 1346 1347 1348
        backgroundColor = themeData.backgroundColor;
        break;
    }

1349
    final ThemeData theme = Theme.of(context);
1350 1351 1352 1353 1354
    List<_TappableLabel> primaryOuterLabels;
    List<_TappableLabel> primaryInnerLabels;
    List<_TappableLabel> secondaryOuterLabels;
    List<_TappableLabel> secondaryInnerLabels;
    int selectedDialValue;
1355
    switch (widget.mode) {
1356
      case _TimePickerMode.hour:
1357
        if (widget.use24HourDials) {
1358
          selectedDialValue = widget.selectedTime.hour;
1359 1360 1361 1362
          primaryOuterLabels = _build24HourOuterRing(theme.textTheme);
          secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme);
          primaryInnerLabels = _build24HourInnerRing(theme.textTheme);
          secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme);
1363
        } else {
1364
          selectedDialValue = widget.selectedTime.hourOfPeriod;
1365 1366
          primaryOuterLabels = _build12HourOuterRing(theme.textTheme);
          secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme);
1367
        }
1368 1369
        break;
      case _TimePickerMode.minute:
1370
        selectedDialValue = widget.selectedTime.minute;
1371
        primaryOuterLabels = _buildMinutes(theme.textTheme);
Yegor's avatar
Yegor committed
1372
        primaryInnerLabels = null;
1373
        secondaryOuterLabels = _buildMinutes(theme.accentTextTheme);
Yegor's avatar
Yegor committed
1374
        secondaryInnerLabels = null;
1375 1376 1377
        break;
    }

1378
    return new GestureDetector(
1379
      excludeFromSemantics: true,
1380 1381 1382
      onPanStart: _handlePanStart,
      onPanUpdate: _handlePanUpdate,
      onPanEnd: _handlePanEnd,
1383
      onTapUp: _handleTapUp,
1384
      child: new CustomPaint(
1385
        key: const ValueKey<String>('time-picker-dial'),
1386
        painter: new _DialPainter(
1387
          selectedValue: selectedDialValue,
Yegor's avatar
Yegor committed
1388 1389 1390 1391
          primaryOuterLabels: primaryOuterLabels,
          primaryInnerLabels: primaryInnerLabels,
          secondaryOuterLabels: secondaryOuterLabels,
          secondaryInnerLabels: secondaryInnerLabels,
1392 1393
          backgroundColor: backgroundColor,
          accentColor: themeData.accentColor,
Yegor's avatar
Yegor committed
1394 1395
          theta: _theta.value,
          activeRing: _activeRing,
1396 1397
          textDirection: Directionality.of(context),
        ),
1398 1399 1400 1401
      )
    );
  }
}
1402

1403 1404 1405 1406 1407 1408
/// A material design time picker designed to appear inside a popup dialog.
///
/// Pass this widget to [showDialog]. The value returned by [showDialog] is the
/// selected [TimeOfDay] if the user taps the "OK" button, or null if the user
/// taps the "CANCEL" button. The selected time is reported by calling
/// [Navigator.pop].
1409
class _TimePickerDialog extends StatefulWidget {
1410 1411 1412
  /// Creates a material time picker.
  ///
  /// [initialTime] must not be null.
1413
  const _TimePickerDialog({
1414
    Key key,
1415
    @required this.initialTime
1416 1417
  }) : assert(initialTime != null),
       super(key: key);
1418

1419
  /// The time initially selected when the dialog is shown.
1420 1421 1422 1423 1424 1425 1426 1427 1428 1429
  final TimeOfDay initialTime;

  @override
  _TimePickerDialogState createState() => new _TimePickerDialogState();
}

class _TimePickerDialogState extends State<_TimePickerDialog> {
  @override
  void initState() {
    super.initState();
1430
    _selectedTime = widget.initialTime;
1431 1432
  }

1433 1434 1435 1436 1437 1438 1439 1440
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    localizations = MaterialLocalizations.of(context);
    _announceInitialTimeOnce();
    _announceModeOnce();
  }

1441
  _TimePickerMode _mode = _TimePickerMode.hour;
1442
  _TimePickerMode _lastModeAnnounced;
1443 1444

  TimeOfDay get selectedTime => _selectedTime;
1445
  TimeOfDay _selectedTime;
1446

1447
  Timer _vibrateTimer;
1448
  MaterialLocalizations localizations;
1449

1450 1451 1452 1453
  void _vibrate() {
    switch (Theme.of(context).platform) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
1454 1455 1456 1457 1458
        _vibrateTimer?.cancel();
        _vibrateTimer = new Timer(_kVibrateCommitDelay, () {
          HapticFeedback.vibrate();
          _vibrateTimer = null;
        });
1459 1460 1461 1462 1463 1464
        break;
      case TargetPlatform.iOS:
        break;
    }
  }

1465
  void _handleModeChanged(_TimePickerMode mode) {
1466
    _vibrate();
1467 1468
    setState(() {
      _mode = mode;
1469
      _announceModeOnce();
1470 1471 1472
    });
  }

1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504
  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;
  }

1505
  void _handleTimeChanged(TimeOfDay value) {
1506
    _vibrate();
1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521
    setState(() {
      _selectedTime = value;
    });
  }

  void _handleCancel() {
    Navigator.pop(context);
  }

  void _handleOk() {
    Navigator.pop(context, _selectedTime);
  }

  @override
  Widget build(BuildContext context) {
1522 1523 1524
    assert(debugCheckHasMediaQuery(context));
    final MediaQueryData media = MediaQuery.of(context);
    final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
1525
    final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
1526
    final ThemeData theme = Theme.of(context);
Yegor's avatar
Yegor committed
1527

1528
    final Widget picker = new Padding(
1529 1530 1531 1532 1533
      padding: const EdgeInsets.all(16.0),
      child: new AspectRatio(
        aspectRatio: 1.0,
        child: new _Dial(
          mode: _mode,
1534
          use24HourDials: use24HourDials,
1535 1536 1537 1538 1539 1540
          selectedTime: _selectedTime,
          onChanged: _handleTimeChanged,
        )
      )
    );

1541
    final Widget actions = new ButtonTheme.bar(
1542 1543 1544
      child: new ButtonBar(
        children: <Widget>[
          new FlatButton(
1545
            child: new Text(localizations.cancelButtonLabel),
1546 1547 1548
            onPressed: _handleCancel
          ),
          new FlatButton(
1549
            child: new Text(localizations.okButtonLabel),
1550 1551 1552 1553 1554 1555
            onPressed: _handleOk
          ),
        ]
      )
    );

1556
    final Dialog dialog = new Dialog(
1557 1558
      child: new OrientationBuilder(
        builder: (BuildContext context, Orientation orientation) {
1559
          final Widget header = new _TimePickerHeader(
1560 1561 1562 1563 1564
            selectedTime: _selectedTime,
            mode: _mode,
            orientation: orientation,
            onModeChanged: _handleModeChanged,
            onChanged: _handleTimeChanged,
1565
            use24HourDials: use24HourDials,
1566 1567
          );

1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578
          final Widget pickerAndActions = new Container(
            color: theme.dialogBackgroundColor,
            child: new Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                new Expanded(child: picker), // picker grows and shrinks with the available space
                actions,
              ],
            ),
          );

1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589
          assert(orientation != null);
          switch (orientation) {
            case Orientation.portrait:
              return new SizedBox(
                width: _kTimePickerWidthPortrait,
                height: _kTimePickerHeightPortrait,
                child: new Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    header,
1590 1591 1592
                    new Expanded(
                      child: pickerAndActions,
                    ),
1593 1594 1595 1596 1597
                  ]
                )
              );
            case Orientation.landscape:
              return new SizedBox(
1598 1599
                width: _kTimePickerWidthLandscape,
                height: _kTimePickerHeightLandscape,
1600 1601 1602 1603 1604 1605
                child: new Row(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    header,
                    new Flexible(
1606
                      child: pickerAndActions,
1607 1608 1609 1610 1611 1612 1613 1614 1615
                    ),
                  ]
                )
              );
          }
          return null;
        }
      )
    );
1616 1617 1618 1619 1620 1621 1622

    return new Theme(
      data: theme.copyWith(
        dialogBackgroundColor: Colors.transparent,
      ),
      child: dialog,
    );
1623
  }
1624 1625 1626 1627 1628 1629 1630

  @override
  void dispose() {
    _vibrateTimer?.cancel();
    _vibrateTimer = null;
    super.dispose();
  }
1631 1632 1633 1634 1635
}

/// Shows a dialog containing a material design time picker.
///
/// The returned Future resolves to the time selected by the user when the user
1636
/// closes the dialog. If the user cancels the dialog, null is returned.
1637 1638
///
/// To show a dialog with [initialTime] equal to the current time:
Ian Hickson's avatar
Ian Hickson committed
1639
///
1640 1641 1642
/// ```dart
/// showTimePicker(
///   initialTime: new TimeOfDay.now(),
Ian Hickson's avatar
Ian Hickson committed
1643
///   context: context,
1644 1645 1646
/// );
/// ```
///
Ian Hickson's avatar
Ian Hickson committed
1647 1648 1649
/// The `context` argument is passed to [showDialog], the documentation for
/// which discusses how it is used.
///
1650 1651 1652
/// See also:
///
///  * [showDatePicker]
1653
///  * <https://material.google.com/components/pickers.html#pickers-time-pickers>
1654
Future<TimeOfDay> showTimePicker({
1655 1656
  @required BuildContext context,
  @required TimeOfDay initialTime
1657
}) async {
1658
  assert(context != null);
1659
  assert(initialTime != null);
1660

1661
  return await showDialog<TimeOfDay>(
1662
    context: context,
1663
    builder: (BuildContext context) => new _TimePickerDialog(initialTime: initialTime),
1664
  );
1665
}
1666 1667 1668 1669

void _announceToAccessibility(BuildContext context, String message) {
  SemanticsService.announce(message, Directionality.of(context));
}