date_picker.dart 35.5 KB
Newer Older
1 2 3 4 5
// 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.

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

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

12
import 'button_bar.dart';
13
import 'button_theme.dart';
14
import 'colors.dart';
15
import 'debug.dart';
16
import 'dialog.dart';
17
import 'feedback.dart';
18
import 'flat_button.dart';
19
import 'icon_button.dart';
Ian Hickson's avatar
Ian Hickson committed
20
import 'icons.dart';
21
import 'ink_well.dart';
22
import 'material.dart';
23
import 'material_localizations.dart';
24
import 'theme.dart';
25
import 'typography.dart';
26

xster's avatar
xster committed
27 28
/// Initial display mode of the date picker dialog.
///
29
/// Date picker UI mode for either showing a list of available years or a
xster's avatar
xster committed
30
/// monthly calendar initially in the dialog shown by calling [showDatePicker].
31 32 33 34 35 36 37
///
/// Also see:
///
///  * <https://material.io/guidelines/components/pickers.html#pickers-date-pickers>
enum DatePickerMode {
  /// Show a date picker UI for choosing a month and day.
  day,
xster's avatar
xster committed
38

39 40 41
  /// Show a date picker UI for choosing a year.
  year,
}
42

43 44
const double _kDatePickerHeaderPortraitHeight = 100.0;
const double _kDatePickerHeaderLandscapeWidth = 168.0;
45

46 47 48 49 50
const Duration _kMonthScrollDuration = const Duration(milliseconds: 200);
const double _kDayPickerRowHeight = 42.0;
const int _kMaxDayPickerRowCount = 6; // A 31 day month that starts on Saturday.
// Two extra rows: one for the day-of-week header and one for the month header.
const double _kMaxDayPickerHeight = _kDayPickerRowHeight * (_kMaxDayPickerRowCount + 2);
51 52 53 54 55 56

const double _kMonthPickerPortraitWidth = 330.0;
const double _kMonthPickerLandscapeWidth = 344.0;

const double _kDialogActionBarHeight = 52.0;
const double _kDatePickerLandscapeHeight = _kMaxDayPickerHeight + _kDialogActionBarHeight;
57 58

// Shows the selected date in large font and toggles between year and day mode
59
class _DatePickerHeader extends StatelessWidget {
60
  const _DatePickerHeader({
61
    Key key,
62 63
    @required this.selectedDate,
    @required this.mode,
64 65
    @required this.onModeChanged,
    @required this.orientation,
66 67 68 69
  }) : assert(selectedDate != null),
       assert(mode != null),
       assert(orientation != null),
       super(key: key);
70

71
  final DateTime selectedDate;
72 73
  final DatePickerMode mode;
  final ValueChanged<DatePickerMode> onModeChanged;
74
  final Orientation orientation;
75

76
  void _handleChangeMode(DatePickerMode value) {
Adam Barth's avatar
Adam Barth committed
77 78
    if (value != mode)
      onModeChanged(value);
79 80
  }

81
  @override
82
  Widget build(BuildContext context) {
Yegor's avatar
Yegor committed
83
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
84 85
    final ThemeData themeData = Theme.of(context);
    final TextTheme headerTextTheme = themeData.primaryTextTheme;
86 87
    Color dayColor;
    Color yearColor;
88
    switch (themeData.primaryColorBrightness) {
89
      case Brightness.light:
90 91
        dayColor = mode == DatePickerMode.day ? Colors.black87 : Colors.black54;
        yearColor = mode == DatePickerMode.year ? Colors.black87 : Colors.black54;
92
        break;
93
      case Brightness.dark:
94 95
        dayColor = mode == DatePickerMode.day ? Colors.white : Colors.white70;
        yearColor = mode == DatePickerMode.year ? Colors.white : Colors.white70;
96 97
        break;
    }
98 99
    final TextStyle dayStyle = headerTextTheme.display1.copyWith(color: dayColor, height: 1.4);
    final TextStyle yearStyle = headerTextTheme.subhead.copyWith(color: yearColor, height: 1.4);
100 101 102

    Color backgroundColor;
    switch (themeData.brightness) {
103
      case Brightness.light:
104 105
        backgroundColor = themeData.primaryColor;
        break;
106
      case Brightness.dark:
107 108 109
        backgroundColor = themeData.backgroundColor;
        break;
    }
110

111
    double width;
112
    double height;
113 114 115 116 117
    EdgeInsets padding;
    MainAxisAlignment mainAxisAlignment;
    switch (orientation) {
      case Orientation.portrait:
        height = _kDatePickerHeaderPortraitHeight;
118
        padding = const EdgeInsets.symmetric(horizontal: 16.0);
119 120 121 122
        mainAxisAlignment = MainAxisAlignment.center;
        break;
      case Orientation.landscape:
        width = _kDatePickerHeaderLandscapeWidth;
123
        padding = const EdgeInsets.all(8.0);
124 125 126 127
        mainAxisAlignment = MainAxisAlignment.start;
        break;
    }

128 129 130 131 132 133 134 135 136 137 138
    final Widget yearButton = new IgnorePointer(
      ignoring: mode != DatePickerMode.day,
      ignoringSemantics: false,
      child: new _DateHeaderButton(
        color: backgroundColor,
        onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.year), context),
        child: new Semantics(
          selected: mode == DatePickerMode.year,
          child: new Text(localizations.formatYear(selectedDate), style: yearStyle),
        ),
      ),
139 140
    );

141 142 143 144 145 146 147 148 149 150 151 152
    final Widget dayButton = new IgnorePointer(
      ignoring: mode == DatePickerMode.day,
      ignoringSemantics: false,
      child: new _DateHeaderButton(
        color: backgroundColor,
        onTap: Feedback.wrapForTap(() => _handleChangeMode(DatePickerMode.day), context),
        child: new Semantics(
          selected: mode == DatePickerMode.day,
          child: new Text(localizations.formatMediumDate(selectedDate), style: dayStyle),
        ),
      ),
    );
153

154
    return new Container(
155 156 157
      width: width,
      height: height,
      padding: padding,
158
      color: backgroundColor,
159
      child: new Column(
160
        mainAxisAlignment: mainAxisAlignment,
161
        crossAxisAlignment: CrossAxisAlignment.start,
162 163 164 165 166 167 168
        children: <Widget>[yearButton, dayButton],
      ),
    );
  }
}

class _DateHeaderButton extends StatelessWidget {
169
  const _DateHeaderButton({
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
    Key key,
    this.onTap,
    this.color,
    this.child,
  }) : super(key: key);

  final VoidCallback onTap;
  final Color color;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);

    return new Material(
      type: MaterialType.button,
      color: color,
      child: new InkWell(
        borderRadius: kMaterialEdges[MaterialType.button],
        highlightColor: theme.highlightColor,
        splashColor: theme.splashColor,
        onTap: onTap,
        child: new Container(
          padding: const EdgeInsets.symmetric(horizontal: 8.0),
          child: child,
        ),
196
      ),
197 198 199 200
    );
  }
}

201 202 203
class _DayPickerGridDelegate extends SliverGridDelegate {
  const _DayPickerGridDelegate();

204
  @override
205
  SliverGridLayout getLayout(SliverConstraints constraints) {
206
    const int columnCount = DateTime.daysPerWeek;
207 208 209 210 211 212 213 214
    final double tileWidth = constraints.crossAxisExtent / columnCount;
    final double tileHeight = math.min(_kDayPickerRowHeight, constraints.viewportMainAxisExtent / (_kMaxDayPickerRowCount + 1));
    return new SliverGridRegularTileLayout(
      crossAxisCount: columnCount,
      mainAxisStride: tileHeight,
      crossAxisStride: tileWidth,
      childMainAxisExtent: tileHeight,
      childCrossAxisExtent: tileWidth,
215
      reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
216 217
    );
  }
218 219 220

  @override
  bool shouldRelayout(_DayPickerGridDelegate oldDelegate) => false;
221 222
}

223
const _DayPickerGridDelegate _kDayPickerGridDelegate = const _DayPickerGridDelegate();
224

225 226 227 228 229
/// Displays the days of a given month and allows choosing a day.
///
/// The days are arranged in a rectangular grid with one column for each day of
/// the week.
///
230 231
/// The day picker widget is rarely used directly. Instead, consider using
/// [showDatePicker], which creates a date picker dialog.
232 233
///
/// See also:
234
///
235
///  * [showDatePicker].
236
///  * <https://material.google.com/components/pickers.html#pickers-date-pickers>
237
class DayPicker extends StatelessWidget {
238 239
  /// Creates a day picker.
  ///
240
  /// Rarely used directly. Instead, typically used as part of a [MonthPicker].
241
  DayPicker({
242
    Key key,
243 244 245
    @required this.selectedDate,
    @required this.currentDate,
    @required this.onChanged,
246 247
    @required this.firstDate,
    @required this.lastDate,
248
    @required this.displayedMonth,
249
    this.selectableDayPredicate,
250 251 252 253 254 255 256
  }) : assert(selectedDate != null),
       assert(currentDate != null),
       assert(onChanged != null),
       assert(displayedMonth != null),
       assert(!firstDate.isAfter(lastDate)),
       assert(selectedDate.isAfter(firstDate) || selectedDate.isAtSameMomentAs(firstDate)),
       super(key: key);
257

258 259 260
  /// The currently selected date.
  ///
  /// This date is highlighted in the picker.
261
  final DateTime selectedDate;
262 263

  /// The current date at the time the picker is displayed.
264
  final DateTime currentDate;
265 266

  /// Called when the user picks a day.
Hixie's avatar
Hixie committed
267
  final ValueChanged<DateTime> onChanged;
268

269 270 271 272 273 274
  /// The earliest date the user is permitted to pick.
  final DateTime firstDate;

  /// The latest date the user is permitted to pick.
  final DateTime lastDate;

275
  /// The month whose days are displayed by this picker.
276 277
  final DateTime displayedMonth;

278 279 280
  /// Optional user supplied predicate function to customize selectable days.
  final SelectableDayPredicate selectableDayPredicate;

Yegor's avatar
Yegor committed
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
  /// Builds widgets showing abbreviated days of week. The first widget in the
  /// returned list corresponds to the first day of week for the current locale.
  ///
  /// Examples:
  ///
  /// ```
  /// ┌ Sunday is the first day of week in the US (en_US)
  /// |
  /// S M T W T F S  <-- the returned list contains these widgets
  /// _ _ _ _ _ 1 2
  /// 3 4 5 6 7 8 9
  ///
  /// ┌ But it's Monday in the UK (en_GB)
  /// |
  /// M T W T F S S  <-- the returned list contains these widgets
  /// _ _ _ _ 1 2 3
  /// 4 5 6 7 8 9 10
  /// ```
  List<Widget> _getDayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) {
    final List<Widget> result = <Widget>[];
    for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) {
302
      final String weekday = localizations.narrowWeekdays[i];
303 304 305
      result.add(new ExcludeSemantics(
        child: new Center(child: new Text(weekday, style: headerStyle)),
      ));
Yegor's avatar
Yegor committed
306 307 308 309
      if (i == (localizations.firstDayOfWeekIndex - 1) % 7)
        break;
    }
    return result;
310 311
  }

312
  // Do not use this directly - call getDaysInMonth instead.
313
  static const List<int> _kDaysInMonth = const <int>[31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
314

315 316 317 318 319
  /// Returns the number of days in a month, according to the proleptic
  /// Gregorian calendar.
  ///
  /// This applies the leap year logic introduced by the Gregorian reforms of
  /// 1582. It will not give valid results for dates prior to that time.
320
  static int getDaysInMonth(int year, int month) {
321
    if (month == DateTime.february) {
322 323 324
      final bool isLeapYear = (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0);
      if (isLeapYear)
        return 29;
325
      return 28;
326 327 328 329
    }
    return _kDaysInMonth[month - 1];
  }

Yegor's avatar
Yegor committed
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
  /// Computes the offset from the first day of week that the first day of the
  /// [month] falls on.
  ///
  /// For example, September 1, 2017 falls on a Friday, which in the calendar
  /// localized for United States English appears as:
  ///
  /// ```
  /// S M T W T F S
  /// _ _ _ _ _ 1 2
  /// ```
  ///
  /// The offset for the first day of the months is the number of leading blanks
  /// in the calendar, i.e. 5.
  ///
  /// The same date localized for the Russian calendar has a different offset,
  /// because the first day of week is Monday rather than Sunday:
  ///
  /// ```
  /// M T W T F S S
  /// _ _ _ _ 1 2 3
  /// ```
  ///
  /// So the offset is 4, rather than 5.
  ///
  /// This code consolidates the following:
  ///
  /// - [DateTime.weekday] provides a 1-based index into days of week, with 1
  ///   falling on Monday.
  /// - [MaterialLocalizations.firstDayOfWeekIndex] provides a 0-based index
359 360
  ///   into the [MaterialLocalizations.narrowWeekdays] list.
  /// - [MaterialLocalizations.narrowWeekdays] list provides localized names of
Yegor's avatar
Yegor committed
361 362 363
  ///   days of week, always starting with Sunday and ending with Saturday.
  int _computeFirstDayOffset(int year, int month, MaterialLocalizations localizations) {
    // 0-based day of week, with 0 representing Monday.
364
    final int weekdayFromMonday = new DateTime(year, month).weekday - 1;
Yegor's avatar
Yegor committed
365 366 367 368 369 370
    // 0-based day of week, with 0 representing Sunday.
    final int firstDayOfWeekFromSunday = localizations.firstDayOfWeekIndex;
    // firstDayOfWeekFromSunday recomputed to be Monday-based
    final int firstDayOfWeekFromMonday = (firstDayOfWeekFromSunday - 1) % 7;
    // Number of days between the first day of week appearing on the calendar,
    // and the day corresponding to the 1-st of the month.
371
    return (weekdayFromMonday - firstDayOfWeekFromMonday) % 7;
Yegor's avatar
Yegor committed
372 373
  }

374
  @override
375
  Widget build(BuildContext context) {
376
    final ThemeData themeData = Theme.of(context);
Yegor's avatar
Yegor committed
377
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
378 379
    final int year = displayedMonth.year;
    final int month = displayedMonth.month;
380
    final int daysInMonth = getDaysInMonth(year, month);
Yegor's avatar
Yegor committed
381
    final int firstDayOffset = _computeFirstDayOffset(year, month, localizations);
382
    final List<Widget> labels = <Widget>[];
Yegor's avatar
Yegor committed
383 384 385 386 387
    labels.addAll(_getDayHeaders(themeData.textTheme.caption, localizations));
    for (int i = 0; true; i += 1) {
      // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on
      // a leap year.
      final int day = i - firstDayOffset + 1;
388 389 390 391
      if (day > daysInMonth)
        break;
      if (day < 1) {
        labels.add(new Container());
392
      } else {
393
        final DateTime dayToBuild = new DateTime(year, month, day);
394 395 396
        final bool disabled = dayToBuild.isAfter(lastDate)
            || dayToBuild.isBefore(firstDate)
            || (selectableDayPredicate != null && !selectableDayPredicate(dayToBuild));
397

Ian Hickson's avatar
Ian Hickson committed
398
        BoxDecoration decoration;
399
        TextStyle itemStyle = themeData.textTheme.body1;
Hans Muller's avatar
Hans Muller committed
400

401 402
        final bool isSelectedDay = selectedDate.year == year && selectedDate.month == month && selectedDate.day == day;
        if (isSelectedDay) {
Hans Muller's avatar
Hans Muller committed
403
          // The selected day gets a circle background highlight, and a contrasting text color.
404
          itemStyle = themeData.accentTextTheme.body2;
405
          decoration = new BoxDecoration(
406
            color: themeData.accentColor,
407
            shape: BoxShape.circle
408
          );
409 410
        } else if (disabled) {
          itemStyle = themeData.textTheme.body1.copyWith(color: themeData.disabledColor);
Hans Muller's avatar
Hans Muller committed
411 412
        } else if (currentDate.year == year && currentDate.month == month && currentDate.day == day) {
          // The current day gets a different text color.
413
          itemStyle = themeData.textTheme.body2.copyWith(color: themeData.accentColor);
Hans Muller's avatar
Hans Muller committed
414
        }
415

416 417 418
        Widget dayWidget = new Container(
          decoration: decoration,
          child: new Center(
419 420 421 422 423 424 425 426 427 428 429 430 431
            child: new Semantics(
              // We want the day of month to be spoken first irrespective of the
              // locale-specific preferences or TextDirection. This is because
              // an accessibility user is more likely to be interested in the
              // day of month before the rest of the date, as they are looking
              // for the day of month. To do that we prepend day of month to the
              // formatted full date.
              label: '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}',
              selected: isSelectedDay,
              child: new ExcludeSemantics(
                child: new Text(localizations.formatDecimal(day), style: itemStyle),
              ),
            ),
432
          ),
433 434 435 436 437 438 439 440
        );

        if (!disabled) {
          dayWidget = new GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: () {
              onChanged(dayToBuild);
            },
441
            child: dayWidget,
442 443 444 445
          );
        }

        labels.add(dayWidget);
446 447 448
      }
    }

449 450 451 452 453 454 455
    return new Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      child: new Column(
        children: <Widget>[
          new Container(
            height: _kDayPickerRowHeight,
            child: new Center(
456 457 458
              child: new ExcludeSemantics(
                child: new Text(
                  localizations.formatMonthYear(displayedMonth),
459 460
                  style: themeData.textTheme.subhead,
                ),
461 462
              ),
            ),
463
          ),
464
          new Flexible(
465 466 467 468 469 470 471
            child: new GridView.custom(
              gridDelegate: _kDayPickerGridDelegate,
              childrenDelegate: new SliverChildListDelegate(labels, addRepaintBoundaries: false),
            ),
          ),
        ],
      ),
472
    );
473 474 475
  }
}

476 477 478 479 480
/// A scrollable list of months to allow picking a month.
///
/// Shows the days of each month in a rectangular grid with one column for each
/// day of the week.
///
481 482
/// The month picker widget is rarely used directly. Instead, consider using
/// [showDatePicker], which creates a date picker dialog.
483 484
///
/// See also:
485
///
486
///  * [showDatePicker]
487
///  * <https://material.google.com/components/pickers.html#pickers-date-pickers>
488
class MonthPicker extends StatefulWidget {
489 490
  /// Creates a month picker.
  ///
491 492
  /// Rarely used directly. Instead, typically used as part of the dialog shown
  /// by [showDatePicker].
493
  MonthPicker({
494
    Key key,
495 496 497
    @required this.selectedDate,
    @required this.onChanged,
    @required this.firstDate,
498
    @required this.lastDate,
499
    this.selectableDayPredicate,
500 501 502 503 504
  }) : assert(selectedDate != null),
       assert(onChanged != null),
       assert(!firstDate.isAfter(lastDate)),
       assert(selectedDate.isAfter(firstDate) || selectedDate.isAtSameMomentAs(firstDate)),
       super(key: key);
505

506 507 508
  /// The currently selected date.
  ///
  /// This date is highlighted in the picker.
509
  final DateTime selectedDate;
510 511

  /// Called when the user picks a month.
Hixie's avatar
Hixie committed
512
  final ValueChanged<DateTime> onChanged;
513 514

  /// The earliest date the user is permitted to pick.
515
  final DateTime firstDate;
516 517

  /// The latest date the user is permitted to pick.
518
  final DateTime lastDate;
519

520 521 522
  /// Optional user supplied predicate function to customize selectable days.
  final SelectableDayPredicate selectableDayPredicate;

523
  @override
524
  _MonthPickerState createState() => new _MonthPickerState();
525
}
526

527
class _MonthPickerState extends State<MonthPicker> {
528
  @override
529 530
  void initState() {
    super.initState();
531
    // Initially display the pre-selected date.
532 533 534
    final int monthPage = _monthDelta(widget.firstDate, widget.selectedDate);
    _dayPickerController = new PageController(initialPage: monthPage);
    _handleMonthPageChanged(monthPage);
535
    _updateCurrentDate();
536 537
  }

538
  @override
539
  void didUpdateWidget(MonthPicker oldWidget) {
540
    super.didUpdateWidget(oldWidget);
541
    if (widget.selectedDate != oldWidget.selectedDate) {
542 543 544
      final int monthPage = _monthDelta(widget.firstDate, widget.selectedDate);
      _dayPickerController = new PageController(initialPage: monthPage);
      _handleMonthPageChanged(monthPage);
545
    }
546 547
  }

548 549 550 551 552 553 554 555 556 557
  MaterialLocalizations localizations;
  TextDirection textDirection;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    localizations = MaterialLocalizations.of(context);
    textDirection = Directionality.of(context);
  }

558 559
  DateTime _todayDate;
  DateTime _currentDisplayedMonthDate;
560
  Timer _timer;
561
  PageController _dayPickerController;
562

563
  void _updateCurrentDate() {
564
    _todayDate = new DateTime.now();
565
    final DateTime tomorrow = new DateTime(_todayDate.year, _todayDate.month, _todayDate.day + 1);
566
    Duration timeUntilTomorrow = tomorrow.difference(_todayDate);
567
    timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding
568
    _timer?.cancel();
569
    _timer = new Timer(timeUntilTomorrow, () {
570 571 572 573 574 575
      setState(() {
        _updateCurrentDate();
      });
    });
  }

576
  static int _monthDelta(DateTime startDate, DateTime endDate) {
Hans Muller's avatar
Hans Muller committed
577 578 579
    return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month;
  }

580 581 582 583 584
  /// Add months to a month truncated date.
  DateTime _addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) {
    return new DateTime(monthDate.year + monthsToAdd ~/ 12, monthDate.month + monthsToAdd % 12);
  }

585
  Widget _buildItems(BuildContext context, int index) {
586
    final DateTime month = _addMonthsToMonthDate(widget.firstDate, index);
587 588
    return new DayPicker(
      key: new ValueKey<DateTime>(month),
589
      selectedDate: widget.selectedDate,
590
      currentDate: _todayDate,
591 592 593
      onChanged: widget.onChanged,
      firstDate: widget.firstDate,
      lastDate: widget.lastDate,
594
      displayedMonth: month,
595
      selectableDayPredicate: widget.selectableDayPredicate,
596
    );
597
  }
598

599
  void _handleNextMonth() {
600 601
    if (!_isDisplayingLastMonth) {
      SemanticsService.announce(localizations.formatMonthYear(_nextMonthDate), textDirection);
602
      _dayPickerController.nextPage(duration: _kMonthScrollDuration, curve: Curves.ease);
603
    }
604 605 606
  }

  void _handlePreviousMonth() {
607 608
    if (!_isDisplayingFirstMonth) {
      SemanticsService.announce(localizations.formatMonthYear(_previousMonthDate), textDirection);
609
      _dayPickerController.previousPage(duration: _kMonthScrollDuration, curve: Curves.ease);
610
    }
611 612 613 614 615
  }

  /// True if the earliest allowable month is displayed.
  bool get _isDisplayingFirstMonth {
    return !_currentDisplayedMonthDate.isAfter(
616
        new DateTime(widget.firstDate.year, widget.firstDate.month));
617 618 619 620 621
  }

  /// True if the latest allowable month is displayed.
  bool get _isDisplayingLastMonth {
    return !_currentDisplayedMonthDate.isBefore(
622
        new DateTime(widget.lastDate.year, widget.lastDate.month));
623 624
  }

625 626 627
  DateTime _previousMonthDate;
  DateTime _nextMonthDate;

628 629
  void _handleMonthPageChanged(int monthPage) {
    setState(() {
630
      _previousMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage - 1);
631
      _currentDisplayedMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage);
632
      _nextMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage + 1);
633
    });
634 635
  }

636
  @override
637
  Widget build(BuildContext context) {
Ian Hickson's avatar
Ian Hickson committed
638
    return new SizedBox(
639
      width: _kMonthPickerPortraitWidth,
640
      height: _kMaxDayPickerHeight,
Ian Hickson's avatar
Ian Hickson committed
641 642
      child: new Stack(
        children: <Widget>[
643 644 645 646 647 648 649 650 651 652
          new Semantics(
            sortKey: _MonthPickerSortKey.calendar,
            child: new PageView.builder(
              key: new ValueKey<DateTime>(widget.selectedDate),
              controller: _dayPickerController,
              scrollDirection: Axis.horizontal,
              itemCount: _monthDelta(widget.firstDate, widget.lastDate) + 1,
              itemBuilder: _buildItems,
              onPageChanged: _handleMonthPageChanged,
            ),
Ian Hickson's avatar
Ian Hickson committed
653
          ),
654
          new PositionedDirectional(
Ian Hickson's avatar
Ian Hickson committed
655
            top: 0.0,
656
            start: 8.0,
657 658 659 660 661 662 663
            child: new Semantics(
              sortKey: _MonthPickerSortKey.previousMonth,
              child: new IconButton(
                icon: const Icon(Icons.chevron_left),
                tooltip: _isDisplayingFirstMonth ? null : '${localizations.previousMonthTooltip} ${localizations.formatMonthYear(_previousMonthDate)}',
                onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth,
              ),
664
            ),
Ian Hickson's avatar
Ian Hickson committed
665
          ),
666
          new PositionedDirectional(
Ian Hickson's avatar
Ian Hickson committed
667
            top: 0.0,
668
            end: 8.0,
669 670 671 672 673 674 675
            child: new Semantics(
              sortKey: _MonthPickerSortKey.nextMonth,
              child: new IconButton(
                icon: const Icon(Icons.chevron_right),
                tooltip: _isDisplayingLastMonth ? null : '${localizations.nextMonthTooltip} ${localizations.formatMonthYear(_nextMonthDate)}',
                onPressed: _isDisplayingLastMonth ? null : _handleNextMonth,
              ),
676 677 678 679
            ),
          ),
        ],
      ),
680 681 682
    );
  }

683
  @override
684
  void dispose() {
685 686
    _timer?.cancel();
    _dayPickerController?.dispose();
687
    super.dispose();
688
  }
689 690
}

691 692 693 694 695 696 697 698 699 700
// Defines semantic traversal order of the top-level widgets inside the month
// picker.
class _MonthPickerSortKey extends OrdinalSortKey {
  static const _MonthPickerSortKey previousMonth = const _MonthPickerSortKey(1.0);
  static const _MonthPickerSortKey nextMonth = const _MonthPickerSortKey(2.0);
  static const _MonthPickerSortKey calendar = const _MonthPickerSortKey(3.0);

  const _MonthPickerSortKey(double order) : super(order);
}

701 702
/// A scrollable list of years to allow picking a year.
///
703 704
/// The year picker widget is rarely used directly. Instead, consider using
/// [showDatePicker], which creates a date picker dialog.
705 706 707 708
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
709
///
710
///  * [showDatePicker]
711
///  * <https://material.google.com/components/pickers.html#pickers-date-pickers>
712
class YearPicker extends StatefulWidget {
713 714 715 716 717
  /// Creates a year picker.
  ///
  /// The [selectedDate] and [onChanged] arguments must not be null. The
  /// [lastDate] must be after the [firstDate].
  ///
718 719
  /// Rarely used directly. Instead, typically used as part of the dialog shown
  /// by [showDatePicker].
720
  YearPicker({
721
    Key key,
722 723 724
    @required this.selectedDate,
    @required this.onChanged,
    @required this.firstDate,
725
    @required this.lastDate,
726 727 728 729
  }) : assert(selectedDate != null),
       assert(onChanged != null),
       assert(!firstDate.isAfter(lastDate)),
       super(key: key);
730

731 732 733
  /// The currently selected date.
  ///
  /// This date is highlighted in the picker.
734
  final DateTime selectedDate;
735 736

  /// Called when the user picks a year.
Hixie's avatar
Hixie committed
737
  final ValueChanged<DateTime> onChanged;
738 739

  /// The earliest date the user is permitted to pick.
740
  final DateTime firstDate;
741 742

  /// The latest date the user is permitted to pick.
743 744
  final DateTime lastDate;

745
  @override
746
  _YearPickerState createState() => new _YearPickerState();
747 748
}

749 750
class _YearPickerState extends State<YearPicker> {
  static const double _itemExtent = 50.0;
751 752 753 754 755 756 757 758 759 760
  ScrollController scrollController;

  @override
  void initState() {
    super.initState();
    scrollController = new ScrollController(
      // Move the initial scroll position to the currently selected date's year.
      initialScrollOffset: (widget.selectedDate.year - widget.firstDate.year) * _itemExtent,
    );
  }
761

762
  @override
763
  Widget build(BuildContext context) {
764
    assert(debugCheckHasMaterial(context));
765 766 767
    final ThemeData themeData = Theme.of(context);
    final TextStyle style = themeData.textTheme.body1;
    return new ListView.builder(
768
      controller: scrollController,
769
      itemExtent: _itemExtent,
770
      itemCount: widget.lastDate.year - widget.firstDate.year + 1,
771
      itemBuilder: (BuildContext context, int index) {
772
        final int year = widget.firstDate.year + index;
773 774 775 776
        final bool isSelected = year == widget.selectedDate.year;
        final TextStyle itemStyle = isSelected
          ? themeData.textTheme.headline.copyWith(color: themeData.accentColor)
          : style;
777 778 779
        return new InkWell(
          key: new ValueKey<int>(year),
          onTap: () {
780
            widget.onChanged(new DateTime(year, widget.selectedDate.month, widget.selectedDate.day));
781 782
          },
          child: new Center(
783 784 785 786
            child: new Semantics(
              selected: isSelected,
              child: new Text(year.toString(), style: itemStyle),
            ),
787 788 789
          ),
        );
      },
790 791
    );
  }
792
}
793 794

class _DatePickerDialog extends StatefulWidget {
795
  const _DatePickerDialog({
796 797 798
    Key key,
    this.initialDate,
    this.firstDate,
799
    this.lastDate,
800
    this.selectableDayPredicate,
801
    this.initialDatePickerMode,
802 803 804 805 806
  }) : super(key: key);

  final DateTime initialDate;
  final DateTime firstDate;
  final DateTime lastDate;
807
  final SelectableDayPredicate selectableDayPredicate;
808
  final DatePickerMode initialDatePickerMode;
809 810 811 812 813 814 815 816 817

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

class _DatePickerDialogState extends State<_DatePickerDialog> {
  @override
  void initState() {
    super.initState();
818
    _selectedDate = widget.initialDate;
819
    _mode = widget.initialDatePickerMode;
820 821
  }

822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840
  bool _announcedInitialDate = false;

  MaterialLocalizations localizations;
  TextDirection textDirection;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    localizations = MaterialLocalizations.of(context);
    textDirection = Directionality.of(context);
    if (!_announcedInitialDate) {
      _announcedInitialDate = true;
      SemanticsService.announce(
        localizations.formatFullDate(_selectedDate),
        textDirection,
      );
    }
  }

841
  DateTime _selectedDate;
842
  DatePickerMode _mode;
843
  final GlobalKey _pickerKey = new GlobalKey();
844

845 846 847 848 849 850 851 852 853 854 855
  void _vibrate() {
    switch (Theme.of(context).platform) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        HapticFeedback.vibrate();
        break;
      case TargetPlatform.iOS:
        break;
    }
  }

856
  void _handleModeChanged(DatePickerMode mode) {
857
    _vibrate();
858 859
    setState(() {
      _mode = mode;
860 861 862 863 864
      if (_mode == DatePickerMode.day) {
        SemanticsService.announce(localizations.formatMonthYear(_selectedDate), textDirection);
      } else {
        SemanticsService.announce(localizations.formatYear(_selectedDate), textDirection);
      }
865 866 867 868
    });
  }

  void _handleYearChanged(DateTime value) {
869
    _vibrate();
870
    setState(() {
871
      _mode = DatePickerMode.day;
872 873 874 875 876
      _selectedDate = value;
    });
  }

  void _handleDayChanged(DateTime value) {
877
    _vibrate();
878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893
    setState(() {
      _selectedDate = value;
    });
  }

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

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

  Widget _buildPicker() {
    assert(_mode != null);
    switch (_mode) {
894
      case DatePickerMode.day:
895
        return new MonthPicker(
896
          key: _pickerKey,
897 898
          selectedDate: _selectedDate,
          onChanged: _handleDayChanged,
899 900 901
          firstDate: widget.firstDate,
          lastDate: widget.lastDate,
          selectableDayPredicate: widget.selectableDayPredicate,
902
        );
903
      case DatePickerMode.year:
904
        return new YearPicker(
905
          key: _pickerKey,
906 907
          selectedDate: _selectedDate,
          onChanged: _handleYearChanged,
908 909
          firstDate: widget.firstDate,
          lastDate: widget.lastDate,
910 911 912 913 914 915 916
        );
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
917
    final ThemeData theme = Theme.of(context);
918
    final Widget picker = new Flexible(
919
      child: new SizedBox(
920 921
        height: _kMaxDayPickerHeight,
        child: _buildPicker(),
922
      ),
923
    );
924
    final Widget actions = new ButtonTheme.bar(
925 926 927
      child: new ButtonBar(
        children: <Widget>[
          new FlatButton(
928
            child: new Text(localizations.cancelButtonLabel),
929
            onPressed: _handleCancel,
930 931
          ),
          new FlatButton(
932
            child: new Text(localizations.okButtonLabel),
933
            onPressed: _handleOk,
934
          ),
935 936
        ],
      ),
937
    );
938
    final Dialog dialog = new Dialog(
939 940
      child: new OrientationBuilder(
        builder: (BuildContext context, Orientation orientation) {
941
          assert(orientation != null);
942
          final Widget header = new _DatePickerHeader(
943 944 945
            selectedDate: _selectedDate,
            mode: _mode,
            onModeChanged: _handleModeChanged,
946
            orientation: orientation,
947 948 949 950 951 952 953 954
          );
          switch (orientation) {
            case Orientation.portrait:
              return new SizedBox(
                width: _kMonthPickerPortraitWidth,
                child: new Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
955 956 957 958 959 960 961 962 963 964 965 966 967 968
                  children: <Widget>[
                    header,
                    new Container(
                      color: theme.dialogBackgroundColor,
                      child: new Column(
                        mainAxisSize: MainAxisSize.min,
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: <Widget>[
                          picker,
                          actions,
                        ],
                      ),
                    ),
                  ],
969
                ),
970 971 972 973 974 975 976 977 978
              );
            case Orientation.landscape:
              return new SizedBox(
                height: _kDatePickerLandscapeHeight,
                child: new Row(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    header,
979
                    new Flexible(
980
                      child: new Container(
981
                        width: _kMonthPickerLandscapeWidth,
982
                        color: theme.dialogBackgroundColor,
983 984 985
                        child: new Column(
                          mainAxisSize: MainAxisSize.min,
                          crossAxisAlignment: CrossAxisAlignment.stretch,
986 987 988 989 990 991
                          children: <Widget>[picker, actions],
                        ),
                      ),
                    ),
                  ],
                ),
992 993 994 995
              );
          }
          return null;
        }
996 997
      )
    );
998 999 1000 1001 1002 1003 1004

    return new Theme(
      data: theme.copyWith(
        dialogBackgroundColor: Colors.transparent,
      ),
      child: dialog,
    );
1005 1006 1007
  }
}

1008 1009 1010 1011 1012
/// Signature for predicating dates for enabled date selections.
///
/// See [showDatePicker].
typedef bool SelectableDayPredicate(DateTime day);

1013 1014 1015
/// Shows a dialog containing a material design date picker.
///
/// The returned [Future] resolves to the date selected by the user when the
1016
/// user closes the dialog. If the user cancels the dialog, null is returned.
1017
///
1018 1019 1020 1021
/// An optional [selectableDayPredicate] function can be passed in to customize
/// the days to enable for selection. If provided, only the days that
/// [selectableDayPredicate] returned true for will be selectable.
///
1022 1023
/// An optional [initialDatePickerMode] argument can be used to display the
/// date picker initially in the year or month+day picker mode. It defaults
xster's avatar
xster committed
1024
/// to month+day, and must not be null.
1025
///
Yegor's avatar
Yegor committed
1026 1027 1028 1029 1030 1031 1032 1033
/// An optional [locale] argument can be used to set the locale for the date
/// picker. It defaults to the ambient locale provided by [Localizations].
///
/// An optional [textDirection] argument can be used to set the text direction
/// (RTL or LTR) for the date picker. It defaults to the ambient text direction
/// provided by [Directionality]. If both [locale] and [textDirection] are not
/// null, [textDirection] overrides the direction chosen for the [locale].
///
Ian Hickson's avatar
Ian Hickson committed
1034 1035 1036
/// The `context` argument is passed to [showDialog], the documentation for
/// which discusses how it is used.
///
1037 1038 1039
/// See also:
///
///  * [showTimePicker]
1040
///  * <https://material.google.com/components/pickers.html#pickers-date-pickers>
1041
Future<DateTime> showDatePicker({
1042 1043 1044
  @required BuildContext context,
  @required DateTime initialDate,
  @required DateTime firstDate,
1045
  @required DateTime lastDate,
1046
  SelectableDayPredicate selectableDayPredicate,
1047
  DatePickerMode initialDatePickerMode: DatePickerMode.day,
Yegor's avatar
Yegor committed
1048 1049
  Locale locale,
  TextDirection textDirection,
1050
}) async {
1051 1052 1053 1054 1055 1056 1057
  assert(!initialDate.isBefore(firstDate), 'initialDate must be on or after firstDate');
  assert(!initialDate.isAfter(lastDate), 'initialDate must be on or before lastDate');
  assert(!firstDate.isAfter(lastDate), 'lastDate must be on or after firstDate');
  assert(
    selectableDayPredicate == null || selectableDayPredicate(initialDate),
    'Provided initialDate must satisfy provided selectableDayPredicate'
  );
1058
  assert(initialDatePickerMode != null, 'initialDatePickerMode must not be null');
Yegor's avatar
Yegor committed
1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083

  Widget child = new _DatePickerDialog(
    initialDate: initialDate,
    firstDate: firstDate,
    lastDate: lastDate,
    selectableDayPredicate: selectableDayPredicate,
    initialDatePickerMode: initialDatePickerMode,
  );

  if (textDirection != null) {
    child = new Directionality(
      textDirection: textDirection,
      child: child,
    );
  }

  if (locale != null) {
    child = new Localizations.override(
      context: context,
      locale: locale,
      child: child,
    );
  }

  return await showDialog<DateTime>(
1084
    context: context,
1085
    builder: (BuildContext context) => child,
1086 1087
  );
}