time_picker.dart 23.8 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/services.dart';
10 11
import 'package:flutter/widgets.dart';

12 13
import 'button_bar.dart';
import 'button.dart';
14
import 'colors.dart';
15 16
import 'dialog.dart';
import 'flat_button.dart';
17 18 19
import 'theme.dart';
import 'typography.dart';

Adam Barth's avatar
Adam Barth committed
20 21 22 23 24 25
const Duration _kDialAnimateDuration = const Duration(milliseconds: 200);
const double _kTwoPi = 2 * math.PI;
const int _kHoursPerDay = 24;
const int _kHoursPerPeriod = 12;
const int _kMinutesPerHour = 60;

26
/// Whether the [TimeOfDay] is before or after noon.
Adam Barth's avatar
Adam Barth committed
27
enum DayPeriod {
28
  /// Ante meridiem (before noon).
Adam Barth's avatar
Adam Barth committed
29
  am,
30 31

  /// Post meridiem (after noon).
Adam Barth's avatar
Adam Barth committed
32 33 34 35
  pm,
}

/// A value representing a time during the day
36
class TimeOfDay {
37 38 39 40
  /// Creates a time of day.
  ///
  /// The [hour] argument must be between 0 and 23, inclusive. The [minute]
  /// argument must be between 0 and 59, inclusive.
Ian Hickson's avatar
Ian Hickson committed
41
  const TimeOfDay({ @required this.hour, @required this.minute });
42

43 44 45 46 47 48 49 50 51 52 53 54
  /// Creates a time of day based on the given time.
  ///
  /// The [hour] is set to the time's hour and the [minute] is set to the time's
  /// minute in the timezone of the given [DateTime].
  TimeOfDay.fromDateTime(DateTime time) : hour = time.hour, minute = time.minute;

  /// Creates a time of day based on the current time.
  ///
  /// The [hour] is set to the current hour and the [minute] is set to the
  /// current minute in the local time zone.
  factory TimeOfDay.now() { return new TimeOfDay.fromDateTime(new DateTime.now()); }

Adam Barth's avatar
Adam Barth committed
55
  /// Returns a new TimeOfDay with the hour and/or minute replaced.
56
  TimeOfDay replacing({ int hour, int minute }) {
Adam Barth's avatar
Adam Barth committed
57 58
    assert(hour == null || (hour >= 0 && hour < _kHoursPerDay));
    assert(minute == null || (minute >= 0 && minute < _kMinutesPerHour));
59 60 61
    return new TimeOfDay(hour: hour ?? this.hour, minute: minute ?? this.minute);
  }

Ian Hickson's avatar
Ian Hickson committed
62
  /// The selected hour, in 24 hour time from 0..23.
63 64 65 66
  final int hour;

  /// The selected minute.
  final int minute;
67

Adam Barth's avatar
Adam Barth committed
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
  /// Whether this time of day is before or after noon.
  DayPeriod get period => hour < _kHoursPerPeriod ? DayPeriod.am : DayPeriod.pm;

  /// Which hour of the current period (e.g., am or pm) this time is.
  int get hourOfPeriod => hour - periodOffset;

  String _addLeadingZeroIfNeeded(int value) {
    if (value < 10)
      return '0$value';
    return value.toString();
  }

  /// A string representing the hour, in 24 hour time (e.g., '04' or '18').
  String get hourLabel => _addLeadingZeroIfNeeded(hour);

  /// A string representing the minute (e.g., '07').
  String get minuteLabel => _addLeadingZeroIfNeeded(minute);

  /// A string representing the hour of the current period (e.g., '4' or '6').
  String get hourOfPeriodLabel {
    // TODO(ianh): Localize.
    final int hourOfPeriod = this.hourOfPeriod;
    if (hourOfPeriod == 0)
      return '12';
    return hourOfPeriod.toString();
  }

  /// A string representing the current period (e.g., 'a.m.').
  String get periodLabel => period == DayPeriod.am ? 'a.m.' : 'p.m.'; // TODO(ianh): Localize.

  /// The hour at which the current period starts.
  int get periodOffset => period == DayPeriod.am ? 0 : _kHoursPerPeriod;

101
  @override
102 103 104 105 106 107 108 109
  bool operator ==(dynamic other) {
    if (other is! TimeOfDay)
      return false;
    final TimeOfDay typedOther = other;
    return typedOther.hour == hour
        && typedOther.minute == minute;
  }

110
  @override
111
  int get hashCode => hashValues(hour, minute);
112

Adam Barth's avatar
Adam Barth committed
113
  // TODO(ianh): Localize.
114
  @override
Adam Barth's avatar
Adam Barth committed
115
  String toString() => '$hourOfPeriodLabel:$minuteLabel $periodLabel';
116 117 118 119
}

enum _TimePickerMode { hour, minute }

120 121
const double _kTimePickerHeaderPortraitHeight = 96.0;
const double _kTimePickerHeaderLandscapeWidth = 168.0;
122

123
const double _kTimePickerWidthPortrait = 328.0;
124
const double _kTimePickerWidthLandscape = 512.0;
125

126
const double _kTimePickerHeightPortrait = 484.0;
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 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
const double _kTimePickerHeightLandscape = 304.0;

const double _kPeriodGap = 8.0;

enum _TimePickerHeaderId {
  hour,
  colon,
  minute,
  period, // AM/PM picker
}

class _TimePickerHeaderLayout extends MultiChildLayoutDelegate {
  _TimePickerHeaderLayout(this.orientation);

  final Orientation orientation;

  @override
  void performLayout(Size size) {
    final BoxConstraints constraints = new BoxConstraints.loose(size);
    final Size hourSize = layoutChild(_TimePickerHeaderId.hour, constraints);
    final Size colonSize = layoutChild(_TimePickerHeaderId.colon, constraints);
    final Size minuteSize = layoutChild(_TimePickerHeaderId.minute, constraints);
    final Size periodSize = layoutChild(_TimePickerHeaderId.period, constraints);

    switch (orientation) {
      // 11:57--period
      //
      // The colon is centered horizontally, the entire layout is centered vertically.
      // The "--" is a _kPeriodGap horizontal gap.
      case Orientation.portrait:
        final double width = colonSize.width / 2.0 + minuteSize.width + _kPeriodGap + periodSize.width;
        final double right = math.max(0.0, size.width / 2.0 - width);

        double x = size.width - right - periodSize.width;
        positionChild(_TimePickerHeaderId.period, new Offset(x, (size.height - periodSize.height) / 2.0));

        x -= minuteSize.width + _kPeriodGap;
        positionChild(_TimePickerHeaderId.minute, new Offset(x, (size.height - minuteSize.height) / 2.0));

        x -= colonSize.width;
        positionChild(_TimePickerHeaderId.colon, new Offset(x, (size.height - colonSize.height) / 2.0));

        x -= hourSize.width;
        positionChild(_TimePickerHeaderId.hour, new Offset(x, (size.height - hourSize.height) / 2.0));
      break;

      // 11:57
      //  --
      // period
      //
      // The colon is centered horizontally, the entire layout is centered vertically.
      // The "--" is a _kPeriodGap vertical gap.
      case Orientation.landscape:
        final double width = colonSize.width / 2.0 + minuteSize.width;
        final double offset = math.max(0.0, size.width / 2.0 - width);
        final double timeHeight = math.max(hourSize.height, colonSize.height);
        final double height = timeHeight + _kPeriodGap + periodSize.height;
        final double timeCenter = (size.height - height) / 2.0 + timeHeight / 2.0;

        double x = size.width - offset - minuteSize.width;
        positionChild(_TimePickerHeaderId.minute, new Offset(x, timeCenter - minuteSize.height / 2.0));

        x -= colonSize.width;
        positionChild(_TimePickerHeaderId.colon, new Offset(x, timeCenter - colonSize.height / 2.0));

        x -= hourSize.width;
        positionChild(_TimePickerHeaderId.hour, new Offset(x, timeCenter - hourSize.height / 2.0));

        x = (size.width - periodSize.width) / 2.0;
        positionChild(_TimePickerHeaderId.period, new Offset(x, timeCenter + timeHeight / 2.0 + _kPeriodGap));
        break;
    }
  }

  @override
  bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation;
}

205

Adam Barth's avatar
Adam Barth committed
206
// TODO(ianh): Localize!
207
class _TimePickerHeader extends StatelessWidget {
Adam Barth's avatar
Adam Barth committed
208
  _TimePickerHeader({
209 210
    @required this.selectedTime,
    @required this.mode,
211
    @required this.orientation,
212
    @required this.onModeChanged,
213
    @required this.onChanged,
Adam Barth's avatar
Adam Barth committed
214
  }) {
215 216
    assert(selectedTime != null);
    assert(mode != null);
217
    assert(orientation != null);
218 219
  }

220 221
  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
222
  final Orientation orientation;
223
  final ValueChanged<_TimePickerMode> onModeChanged;
Adam Barth's avatar
Adam Barth committed
224
  final ValueChanged<TimeOfDay> onChanged;
225 226 227 228 229 230

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

Adam Barth's avatar
Adam Barth committed
231 232 233 234 235
  void _handleChangeDayPeriod() {
    int newHour = (selectedTime.hour + _kHoursPerPeriod) % _kHoursPerDay;
    onChanged(selectedTime.replacing(hour: newHour));
  }

236 237 238 239 240 241 242 243 244 245 246 247 248 249
  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;
  }

250
  @override
251
  Widget build(BuildContext context) {
252 253
    ThemeData themeData = Theme.of(context);
    TextTheme headerTextTheme = themeData.primaryTextTheme;
254
    TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme);
255 256
    Color activeColor;
    Color inactiveColor;
257
    switch(themeData.primaryColorBrightness) {
258
      case Brightness.light:
259 260 261
        activeColor = Colors.black87;
        inactiveColor = Colors.black54;
        break;
262
      case Brightness.dark:
263 264 265 266
        activeColor = Colors.white;
        inactiveColor = Colors.white70;
        break;
    }
267 268 269

    Color backgroundColor;
    switch (themeData.brightness) {
270
      case Brightness.light:
271 272
        backgroundColor = themeData.primaryColor;
        break;
273
      case Brightness.dark:
274 275 276 277
        backgroundColor = themeData.backgroundColor;
        break;
    }

278 279
    TextStyle activeStyle = baseHeaderStyle.copyWith(color: activeColor);
    TextStyle inactiveStyle = baseHeaderStyle.copyWith(color: inactiveColor);
280 281 282 283

    TextStyle hourStyle = mode == _TimePickerMode.hour ? activeStyle : inactiveStyle;
    TextStyle minuteStyle = mode == _TimePickerMode.minute ? activeStyle : inactiveStyle;

284
    TextStyle amStyle = headerTextTheme.subhead.copyWith(
Adam Barth's avatar
Adam Barth committed
285 286
      color: selectedTime.period == DayPeriod.am ? activeColor: inactiveColor
    );
287
    TextStyle pmStyle = headerTextTheme.subhead.copyWith(
Adam Barth's avatar
Adam Barth committed
288 289 290
      color: selectedTime.period == DayPeriod.pm ? activeColor: inactiveColor
    );

291 292 293 294 295
    Widget dayPeriodPicker = new GestureDetector(
      onTap: _handleChangeDayPeriod,
      behavior: HitTestBehavior.opaque,
      child: new Column(
        mainAxisSize: MainAxisSize.min,
296
        children: <Widget>[
297 298 299
          new Text('AM', style: amStyle),
          const SizedBox(width: 0.0, height: 4.0),  // Vertical spacer
          new Text('PM', style: pmStyle),
300
        ]
301
      )
302
    );
303

304 305 306
    Widget hour = new GestureDetector(
      onTap: () => _handleChangeMode(_TimePickerMode.hour),
      child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle),
307 308 309 310 311 312 313
    );

    Widget minute = new GestureDetector(
      onTap: () => _handleChangeMode(_TimePickerMode.minute),
      child: new Text(selectedTime.minuteLabel, style: minuteStyle),
    );

314 315 316 317 318 319
    Widget colon = new Text(':', style: inactiveStyle);

    EdgeInsets padding;
    double height;
    double width;

320
    assert(orientation != null);
321
    switch(orientation) {
322
      case Orientation.portrait:
323 324 325
        height = _kTimePickerHeaderPortraitHeight;
        padding = const EdgeInsets.symmetric(horizontal: 24.0);
        break;
326
      case Orientation.landscape:
327 328 329
        width = _kTimePickerHeaderLandscapeWidth;
        padding = const EdgeInsets.symmetric(horizontal: 16.0);
        break;
330
    }
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346

    return new Container(
      width: width,
      height: height,
      padding: padding,
      decoration: new BoxDecoration(backgroundColor: backgroundColor),
      child: new CustomMultiChildLayout(
        delegate: new _TimePickerHeaderLayout(orientation),
        children: <Widget>[
          new LayoutId(id: _TimePickerHeaderId.hour, child: hour),
          new LayoutId(id: _TimePickerHeaderId.colon, child: colon),
          new LayoutId(id: _TimePickerHeaderId.minute, child: minute),
          new LayoutId(id: _TimePickerHeaderId.period, child: dayPeriodPicker),
        ],
      )
    );
347 348
  }
}
349

350 351
List<TextPainter> _initPainters(TextTheme textTheme, List<String> labels) {
  TextStyle style = textTheme.subhead;
352 353 354
  List<TextPainter> painters = new List<TextPainter>(labels.length);
  for (int i = 0; i < painters.length; ++i) {
    String label = labels[i];
355 356
    // TODO(abarth): Handle textScaleFactor.
    // https://github.com/flutter/flutter/issues/5939
357
    painters[i] = new TextPainter(
358
      text: new TextSpan(style: style, text: label)
359
    )..layout();
360 361 362 363
  }
  return painters;
}

364 365 366 367
List<TextPainter> _initHours(TextTheme textTheme) {
  return _initPainters(textTheme, <String>[
    '12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'
  ]);
368 369
}

370 371 372 373
List<TextPainter> _initMinutes(TextTheme textTheme) {
  return _initPainters(textTheme, <String>[
    '00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'
  ]);
374 375 376 377
}

class _DialPainter extends CustomPainter {
  const _DialPainter({
378 379 380 381
    this.primaryLabels,
    this.secondaryLabels,
    this.backgroundColor,
    this.accentColor,
382 383 384
    this.theta
  });

385 386 387 388
  final List<TextPainter> primaryLabels;
  final List<TextPainter> secondaryLabels;
  final Color backgroundColor;
  final Color accentColor;
389 390
  final double theta;

391
  @override
392 393 394 395
  void paint(Canvas canvas, Size size) {
    double radius = size.shortestSide / 2.0;
    Offset center = new Offset(size.width / 2.0, size.height / 2.0);
    Point centerPoint = center.toPoint();
396
    canvas.drawCircle(centerPoint, radius, new Paint()..color = backgroundColor);
397 398 399 400 401 402 403 404

    const double labelPadding = 24.0;
    double labelRadius = radius - labelPadding;
    Offset getOffsetForTheta(double theta) {
      return center + new Offset(labelRadius * math.cos(theta),
                                 -labelRadius * math.sin(theta));
    }

405 406 407 408 409 410 411 412 413
    void paintLabels(List<TextPainter> labels) {
      double labelThetaIncrement = -_kTwoPi / labels.length;
      double labelTheta = math.PI / 2.0;

      for (TextPainter label in labels) {
        Offset labelOffset = new Offset(-label.width / 2.0, -label.height / 2.0);
        label.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset);
        labelTheta += labelThetaIncrement;
      }
414
    }
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434

    paintLabels(primaryLabels);

    final Paint selectorPaint = new Paint()
      ..color = accentColor;
    final Point focusedPoint = getOffsetForTheta(theta).toPoint();
    final double focusedRadius = labelPadding - 4.0;
    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
      ..saveLayer(focusedRect, new Paint())
      ..clipPath(new Path()..addOval(focusedRect));
    paintLabels(secondaryLabels);
    canvas.restore();
435 436
  }

437
  @override
438
  bool shouldRepaint(_DialPainter oldPainter) {
439 440 441 442
    return oldPainter.primaryLabels != primaryLabels
        || oldPainter.secondaryLabels != secondaryLabels
        || oldPainter.backgroundColor != backgroundColor
        || oldPainter.accentColor != accentColor
443 444 445 446
        || oldPainter.theta != theta;
  }
}

447
class _Dial extends StatefulWidget {
448
  _Dial({
449 450 451
    @required this.selectedTime,
    @required this.mode,
    @required this.onChanged
452 453 454 455 456 457 458 459
  }) {
    assert(selectedTime != null);
  }

  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
  final ValueChanged<TimeOfDay> onChanged;

460
  @override
461 462 463
  _DialState createState() => new _DialState();
}

464
class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
465
  @override
466 467
  void initState() {
    super.initState();
468 469 470 471
    _thetaController = new AnimationController(
      duration: _kDialAnimateDuration,
      vsync: this,
    );
472 473 474
    _thetaTween = new Tween<double>(begin: _getThetaForTime(config.selectedTime));
    _theta = _thetaTween.animate(new CurvedAnimation(
      parent: _thetaController,
475
      curve: Curves.fastOutSlowIn
476
    ))..addListener(() => setState(() { }));
477 478
  }

479
  @override
480
  void didUpdateConfig(_Dial oldConfig) {
Adam Barth's avatar
Adam Barth committed
481 482 483 484
    if (config.mode != oldConfig.mode && !_dragging)
      _animateTo(_getThetaForTime(config.selectedTime));
  }

485 486 487 488 489 490
  @override
  void dispose() {
    _thetaController.dispose();
    super.dispose();
  }

491
  Tween<double> _thetaTween;
492
  Animation<double> _theta;
493
  AnimationController _thetaController;
Adam Barth's avatar
Adam Barth committed
494 495 496 497 498 499 500 501 502 503
  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) {
    double currentTheta = _theta.value;
    double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi);
    beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi);
504 505 506 507 508 509
    _thetaTween
      ..begin = beginTheta
      ..end = targetTheta;
    _thetaController
      ..value = 0.0
      ..forward();
510 511 512 513
  }

  double _getThetaForTime(TimeOfDay time) {
    double fraction = (config.mode == _TimePickerMode.hour) ?
Adam Barth's avatar
Adam Barth committed
514 515 516
        (time.hour / _kHoursPerPeriod) % _kHoursPerPeriod :
        (time.minute / _kMinutesPerHour) % _kMinutesPerHour;
    return (math.PI / 2.0 - fraction * _kTwoPi) % _kTwoPi;
517 518 519
  }

  TimeOfDay _getTimeForTheta(double theta) {
Adam Barth's avatar
Adam Barth committed
520
    double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
521
    if (config.mode == _TimePickerMode.hour) {
Adam Barth's avatar
Adam Barth committed
522
      int hourOfPeriod = (fraction * _kHoursPerPeriod).round() % _kHoursPerPeriod;
523
      return config.selectedTime.replacing(
Adam Barth's avatar
Adam Barth committed
524
        hour: hourOfPeriod + config.selectedTime.periodOffset
525 526 527
      );
    } else {
      return config.selectedTime.replacing(
Adam Barth's avatar
Adam Barth committed
528
        minute: (fraction * _kMinutesPerHour).round() % _kMinutesPerHour
529 530 531 532 533 534 535
      );
    }
  }

  void _notifyOnChangedIfNeeded() {
    if (config.onChanged == null)
      return;
Adam Barth's avatar
Adam Barth committed
536
    TimeOfDay current = _getTimeForTheta(_theta.value);
537 538 539 540 541 542
    if (current != config.selectedTime)
      config.onChanged(current);
  }

  void _updateThetaForPan() {
    setState(() {
Hans Muller's avatar
Hans Muller committed
543 544
      final Offset offset = _position - _center;
      final double angle = (math.atan2(offset.dx, offset.dy) - math.PI / 2.0) % _kTwoPi;
545
      _thetaTween
Hans Muller's avatar
Hans Muller committed
546 547
        ..begin = angle
        ..end = angle; // The controller doesn't animate during the pan gesture.
548 549 550 551 552 553
    });
  }

  Point _position;
  Point _center;

554
  void _handlePanStart(DragStartDetails details) {
Adam Barth's avatar
Adam Barth committed
555 556
    assert(!_dragging);
    _dragging = true;
557
    final RenderBox box = context.findRenderObject();
558
    _position = box.globalToLocal(details.globalPosition);
559
    _center = box.size.center(Point.origin);
560 561 562 563
    _updateThetaForPan();
    _notifyOnChangedIfNeeded();
  }

564 565
  void _handlePanUpdate(DragUpdateDetails details) {
    _position += details.delta;
566 567 568 569
    _updateThetaForPan();
    _notifyOnChangedIfNeeded();
  }

570
  void _handlePanEnd(DragEndDetails details) {
Adam Barth's avatar
Adam Barth committed
571 572
    assert(_dragging);
    _dragging = false;
573 574
    _position = null;
    _center = null;
Adam Barth's avatar
Adam Barth committed
575
    _animateTo(_getThetaForTime(config.selectedTime));
576 577
  }

578
  @override
579
  Widget build(BuildContext context) {
580 581 582 583
    ThemeData themeData = Theme.of(context);

    Color backgroundColor;
    switch (themeData.brightness) {
584
      case Brightness.light:
585 586
        backgroundColor = Colors.grey[200];
        break;
587
      case Brightness.dark:
588 589 590 591
        backgroundColor = themeData.backgroundColor;
        break;
    }

592
    ThemeData theme = Theme.of(context);
593 594 595 596
    List<TextPainter> primaryLabels;
    List<TextPainter> secondaryLabels;
    switch (config.mode) {
      case _TimePickerMode.hour:
597
        primaryLabels = _initHours(theme.textTheme);
598
        secondaryLabels = _initHours(theme.accentTextTheme);
599 600
        break;
      case _TimePickerMode.minute:
601
        primaryLabels = _initMinutes(theme.textTheme);
602
        secondaryLabels = _initMinutes(theme.accentTextTheme);
603 604 605
        break;
    }

606 607 608 609 610
    return new GestureDetector(
      onPanStart: _handlePanStart,
      onPanUpdate: _handlePanUpdate,
      onPanEnd: _handlePanEnd,
      child: new CustomPaint(
611
        key: const ValueKey<String>('time-picker-dial'), // used for testing.
612
        painter: new _DialPainter(
613 614 615 616
          primaryLabels: primaryLabels,
          secondaryLabels: secondaryLabels,
          backgroundColor: backgroundColor,
          accentColor: themeData.accentColor,
Adam Barth's avatar
Adam Barth committed
617
          theta: _theta.value
618 619 620 621 622
        )
      )
    );
  }
}
623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719

class _TimePickerDialog extends StatefulWidget {
  _TimePickerDialog({
    Key key,
    this.initialTime
  }) : super(key: key) {
    assert(initialTime != null);
  }

  final TimeOfDay initialTime;

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

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

  _TimePickerMode _mode = _TimePickerMode.hour;
  TimeOfDay _selectedTime;

  void _handleModeChanged(_TimePickerMode mode) {
    HapticFeedback.vibrate();
    setState(() {
      _mode = mode;
    });
  }

  void _handleTimeChanged(TimeOfDay value) {
    setState(() {
      _selectedTime = value;
    });
  }

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

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

  @override
  Widget build(BuildContext context) {
    Widget picker = new Padding(
      padding: const EdgeInsets.all(16.0),
      child: new AspectRatio(
        aspectRatio: 1.0,
        child: new _Dial(
          mode: _mode,
          selectedTime: _selectedTime,
          onChanged: _handleTimeChanged,
        )
      )
    );

    Widget actions = new ButtonTheme.bar(
      child: new ButtonBar(
        children: <Widget>[
          new FlatButton(
            child: new Text('CANCEL'),
            onPressed: _handleCancel
          ),
          new FlatButton(
            child: new Text('OK'),
            onPressed: _handleOk
          ),
        ]
      )
    );

    return new Dialog(
      child: new OrientationBuilder(
        builder: (BuildContext context, Orientation orientation) {
          Widget header = new _TimePickerHeader(
            selectedTime: _selectedTime,
            mode: _mode,
            orientation: orientation,
            onModeChanged: _handleModeChanged,
            onChanged: _handleTimeChanged,
          );

          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,
720
                    new Expanded(child: picker),
721 722 723 724 725 726
                    actions,
                  ]
                )
              );
            case Orientation.landscape:
              return new SizedBox(
727 728
                width: _kTimePickerWidthLandscape,
                height: _kTimePickerHeightLandscape,
729 730 731 732 733 734 735 736 737
                child: new Row(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    header,
                    new Flexible(
                      fit: FlexFit.loose,
                      child: new Column(
                        children: <Widget>[
738
                          new Expanded(child: picker),
739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756
                          actions,
                        ]
                      )
                    ),
                  ]
                )
              );
          }
          return null;
        }
      )
    );
  }
}

/// Shows a dialog containing a material design time picker.
///
/// The returned Future resolves to the time selected by the user when the user
757
/// closes the dialog. If the user cancels the dialog, null is returned.
758 759 760 761 762 763 764 765 766 767 768 769
///
/// To show a dialog with [initialTime] equal to the current time:
/// ```dart
/// showTimePicker(
///   initialTime: new TimeOfDay.now(),
///   context: context
/// );
/// ```
///
/// See also:
///
///  * [showDatePicker]
770
///  * <https://material.google.com/components/pickers.html#pickers-time-pickers>
771 772 773 774 775 776 777 778
Future<TimeOfDay> showTimePicker({
  BuildContext context,
  TimeOfDay initialTime
}) async {
  assert(initialTime != null);
  return await showDialog(
    context: context,
    child: new _TimePickerDialog(initialTime: initialTime)
779
  );
780
}