date_picker.dart 36.6 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
const Duration _kMonthScrollDuration = Duration(milliseconds: 200);
47 48 49 50
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 = _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> _daysInMonth = <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
    return _daysInMonth[month - 1];
328 329
  }

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> with SingleTickerProviderStateMixin {
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

    // Setup the fade animation for chevrons
    _chevronOpacityController = new AnimationController(
539
      duration: const Duration(milliseconds: 250), vsync: this
540
    );
541
    _chevronOpacityAnimation = new Tween<double>(begin: 1.0, end: 0.0).animate(
542 543 544 545 546
      new CurvedAnimation(
        parent: _chevronOpacityController,
        curve: Curves.easeInOut,
      )
    );
547 548
  }

549
  @override
550
  void didUpdateWidget(MonthPicker oldWidget) {
551
    super.didUpdateWidget(oldWidget);
552
    if (widget.selectedDate != oldWidget.selectedDate) {
553 554 555
      final int monthPage = _monthDelta(widget.firstDate, widget.selectedDate);
      _dayPickerController = new PageController(initialPage: monthPage);
      _handleMonthPageChanged(monthPage);
556
    }
557 558
  }

559 560 561 562 563 564 565 566 567 568
  MaterialLocalizations localizations;
  TextDirection textDirection;

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

569 570
  DateTime _todayDate;
  DateTime _currentDisplayedMonthDate;
571
  Timer _timer;
572
  PageController _dayPickerController;
573 574
  AnimationController _chevronOpacityController;
  Animation<double> _chevronOpacityAnimation;
575

576
  void _updateCurrentDate() {
577
    _todayDate = new DateTime.now();
578
    final DateTime tomorrow = new DateTime(_todayDate.year, _todayDate.month, _todayDate.day + 1);
579
    Duration timeUntilTomorrow = tomorrow.difference(_todayDate);
580
    timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding
581
    _timer?.cancel();
582
    _timer = new Timer(timeUntilTomorrow, () {
583 584 585 586 587 588
      setState(() {
        _updateCurrentDate();
      });
    });
  }

589
  static int _monthDelta(DateTime startDate, DateTime endDate) {
Hans Muller's avatar
Hans Muller committed
590 591 592
    return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month;
  }

593 594 595 596 597
  /// Add months to a month truncated date.
  DateTime _addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) {
    return new DateTime(monthDate.year + monthsToAdd ~/ 12, monthDate.month + monthsToAdd % 12);
  }

598
  Widget _buildItems(BuildContext context, int index) {
599
    final DateTime month = _addMonthsToMonthDate(widget.firstDate, index);
600 601
    return new DayPicker(
      key: new ValueKey<DateTime>(month),
602
      selectedDate: widget.selectedDate,
603
      currentDate: _todayDate,
604 605 606
      onChanged: widget.onChanged,
      firstDate: widget.firstDate,
      lastDate: widget.lastDate,
607
      displayedMonth: month,
608
      selectableDayPredicate: widget.selectableDayPredicate,
609
    );
610
  }
611

612
  void _handleNextMonth() {
613 614
    if (!_isDisplayingLastMonth) {
      SemanticsService.announce(localizations.formatMonthYear(_nextMonthDate), textDirection);
615
      _dayPickerController.nextPage(duration: _kMonthScrollDuration, curve: Curves.ease);
616
    }
617 618 619
  }

  void _handlePreviousMonth() {
620 621
    if (!_isDisplayingFirstMonth) {
      SemanticsService.announce(localizations.formatMonthYear(_previousMonthDate), textDirection);
622
      _dayPickerController.previousPage(duration: _kMonthScrollDuration, curve: Curves.ease);
623
    }
624 625 626 627 628
  }

  /// True if the earliest allowable month is displayed.
  bool get _isDisplayingFirstMonth {
    return !_currentDisplayedMonthDate.isAfter(
629
        new DateTime(widget.firstDate.year, widget.firstDate.month));
630 631 632 633 634
  }

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

638 639 640
  DateTime _previousMonthDate;
  DateTime _nextMonthDate;

641 642
  void _handleMonthPageChanged(int monthPage) {
    setState(() {
643
      _previousMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage - 1);
644
      _currentDisplayedMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage);
645
      _nextMonthDate = _addMonthsToMonthDate(widget.firstDate, monthPage + 1);
646
    });
647 648
  }

649
  @override
650
  Widget build(BuildContext context) {
Ian Hickson's avatar
Ian Hickson committed
651
    return new SizedBox(
652
      width: _kMonthPickerPortraitWidth,
653
      height: _kMaxDayPickerHeight,
Ian Hickson's avatar
Ian Hickson committed
654 655
      child: new Stack(
        children: <Widget>[
656 657
          new Semantics(
            sortKey: _MonthPickerSortKey.calendar,
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676
            child: new NotificationListener<ScrollStartNotification>(
              onNotification: (_) {
                _chevronOpacityController.forward();
                return false;
              },
              child: new NotificationListener<ScrollEndNotification>(
                onNotification: (_) {
                  _chevronOpacityController.reverse();
                  return false;
                },
                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,
                ),
              ),
677
            ),
Ian Hickson's avatar
Ian Hickson committed
678
          ),
679
          new PositionedDirectional(
Ian Hickson's avatar
Ian Hickson committed
680
            top: 0.0,
681
            start: 8.0,
682 683
            child: new Semantics(
              sortKey: _MonthPickerSortKey.previousMonth,
684 685 686 687 688 689 690
              child: new FadeTransition(
                opacity: _chevronOpacityAnimation,
                child: new IconButton(
                  icon: const Icon(Icons.chevron_left),
                  tooltip: _isDisplayingFirstMonth ? null : '${localizations.previousMonthTooltip} ${localizations.formatMonthYear(_previousMonthDate)}',
                  onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth,
                ),
691
              ),
692
            ),
Ian Hickson's avatar
Ian Hickson committed
693
          ),
694
          new PositionedDirectional(
Ian Hickson's avatar
Ian Hickson committed
695
            top: 0.0,
696
            end: 8.0,
697 698
            child: new Semantics(
              sortKey: _MonthPickerSortKey.nextMonth,
699 700 701 702 703 704 705
              child: new FadeTransition(
                opacity: _chevronOpacityAnimation,
                child: new IconButton(
                  icon: const Icon(Icons.chevron_right),
                  tooltip: _isDisplayingLastMonth ? null : '${localizations.nextMonthTooltip} ${localizations.formatMonthYear(_nextMonthDate)}',
                  onPressed: _isDisplayingLastMonth ? null : _handleNextMonth,
                ),
706
              ),
707 708 709 710
            ),
          ),
        ],
      ),
711 712 713
    );
  }

714
  @override
715
  void dispose() {
716 717
    _timer?.cancel();
    _dayPickerController?.dispose();
718
    super.dispose();
719
  }
720 721
}

722 723 724
// Defines semantic traversal order of the top-level widgets inside the month
// picker.
class _MonthPickerSortKey extends OrdinalSortKey {
725 726 727
  static const _MonthPickerSortKey previousMonth = _MonthPickerSortKey(1.0);
  static const _MonthPickerSortKey nextMonth = _MonthPickerSortKey(2.0);
  static const _MonthPickerSortKey calendar = _MonthPickerSortKey(3.0);
728 729 730 731

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

732 733
/// A scrollable list of years to allow picking a year.
///
734 735
/// The year picker widget is rarely used directly. Instead, consider using
/// [showDatePicker], which creates a date picker dialog.
736 737 738 739
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
740
///
741
///  * [showDatePicker]
742
///  * <https://material.google.com/components/pickers.html#pickers-date-pickers>
743
class YearPicker extends StatefulWidget {
744 745 746 747 748
  /// Creates a year picker.
  ///
  /// The [selectedDate] and [onChanged] arguments must not be null. The
  /// [lastDate] must be after the [firstDate].
  ///
749 750
  /// Rarely used directly. Instead, typically used as part of the dialog shown
  /// by [showDatePicker].
751
  YearPicker({
752
    Key key,
753 754 755
    @required this.selectedDate,
    @required this.onChanged,
    @required this.firstDate,
756
    @required this.lastDate,
757 758 759 760
  }) : assert(selectedDate != null),
       assert(onChanged != null),
       assert(!firstDate.isAfter(lastDate)),
       super(key: key);
761

762 763 764
  /// The currently selected date.
  ///
  /// This date is highlighted in the picker.
765
  final DateTime selectedDate;
766 767

  /// Called when the user picks a year.
Hixie's avatar
Hixie committed
768
  final ValueChanged<DateTime> onChanged;
769 770

  /// The earliest date the user is permitted to pick.
771
  final DateTime firstDate;
772 773

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

776
  @override
777
  _YearPickerState createState() => new _YearPickerState();
778 779
}

780 781
class _YearPickerState extends State<YearPicker> {
  static const double _itemExtent = 50.0;
782 783 784 785 786 787 788 789 790 791
  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,
    );
  }
792

793
  @override
794
  Widget build(BuildContext context) {
795
    assert(debugCheckHasMaterial(context));
796 797 798
    final ThemeData themeData = Theme.of(context);
    final TextStyle style = themeData.textTheme.body1;
    return new ListView.builder(
799
      controller: scrollController,
800
      itemExtent: _itemExtent,
801
      itemCount: widget.lastDate.year - widget.firstDate.year + 1,
802
      itemBuilder: (BuildContext context, int index) {
803
        final int year = widget.firstDate.year + index;
804 805 806 807
        final bool isSelected = year == widget.selectedDate.year;
        final TextStyle itemStyle = isSelected
          ? themeData.textTheme.headline.copyWith(color: themeData.accentColor)
          : style;
808 809 810
        return new InkWell(
          key: new ValueKey<int>(year),
          onTap: () {
811
            widget.onChanged(new DateTime(year, widget.selectedDate.month, widget.selectedDate.day));
812 813
          },
          child: new Center(
814 815 816 817
            child: new Semantics(
              selected: isSelected,
              child: new Text(year.toString(), style: itemStyle),
            ),
818 819 820
          ),
        );
      },
821 822
    );
  }
823
}
824 825

class _DatePickerDialog extends StatefulWidget {
826
  const _DatePickerDialog({
827 828 829
    Key key,
    this.initialDate,
    this.firstDate,
830
    this.lastDate,
831
    this.selectableDayPredicate,
832
    this.initialDatePickerMode,
833 834 835 836 837
  }) : super(key: key);

  final DateTime initialDate;
  final DateTime firstDate;
  final DateTime lastDate;
838
  final SelectableDayPredicate selectableDayPredicate;
839
  final DatePickerMode initialDatePickerMode;
840 841 842 843 844 845 846 847 848

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

class _DatePickerDialogState extends State<_DatePickerDialog> {
  @override
  void initState() {
    super.initState();
849
    _selectedDate = widget.initialDate;
850
    _mode = widget.initialDatePickerMode;
851 852
  }

853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871
  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,
      );
    }
  }

872
  DateTime _selectedDate;
873
  DatePickerMode _mode;
874
  final GlobalKey _pickerKey = new GlobalKey();
875

876 877 878 879 880 881 882 883 884 885 886
  void _vibrate() {
    switch (Theme.of(context).platform) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        HapticFeedback.vibrate();
        break;
      case TargetPlatform.iOS:
        break;
    }
  }

887
  void _handleModeChanged(DatePickerMode mode) {
888
    _vibrate();
889 890
    setState(() {
      _mode = mode;
891 892 893 894 895
      if (_mode == DatePickerMode.day) {
        SemanticsService.announce(localizations.formatMonthYear(_selectedDate), textDirection);
      } else {
        SemanticsService.announce(localizations.formatYear(_selectedDate), textDirection);
      }
896 897 898 899
    });
  }

  void _handleYearChanged(DateTime value) {
900
    _vibrate();
901
    setState(() {
902
      _mode = DatePickerMode.day;
903 904 905 906 907
      _selectedDate = value;
    });
  }

  void _handleDayChanged(DateTime value) {
908
    _vibrate();
909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924
    setState(() {
      _selectedDate = value;
    });
  }

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

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

  Widget _buildPicker() {
    assert(_mode != null);
    switch (_mode) {
925
      case DatePickerMode.day:
926
        return new MonthPicker(
927
          key: _pickerKey,
928 929
          selectedDate: _selectedDate,
          onChanged: _handleDayChanged,
930 931 932
          firstDate: widget.firstDate,
          lastDate: widget.lastDate,
          selectableDayPredicate: widget.selectableDayPredicate,
933
        );
934
      case DatePickerMode.year:
935
        return new YearPicker(
936
          key: _pickerKey,
937 938
          selectedDate: _selectedDate,
          onChanged: _handleYearChanged,
939 940
          firstDate: widget.firstDate,
          lastDate: widget.lastDate,
941 942 943 944 945 946 947
        );
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
948
    final ThemeData theme = Theme.of(context);
949
    final Widget picker = new Flexible(
950
      child: new SizedBox(
951 952
        height: _kMaxDayPickerHeight,
        child: _buildPicker(),
953
      ),
954
    );
955
    final Widget actions = new ButtonTheme.bar(
956 957 958
      child: new ButtonBar(
        children: <Widget>[
          new FlatButton(
959
            child: new Text(localizations.cancelButtonLabel),
960
            onPressed: _handleCancel,
961 962
          ),
          new FlatButton(
963
            child: new Text(localizations.okButtonLabel),
964
            onPressed: _handleOk,
965
          ),
966 967
        ],
      ),
968
    );
969
    final Dialog dialog = new Dialog(
970 971
      child: new OrientationBuilder(
        builder: (BuildContext context, Orientation orientation) {
972
          assert(orientation != null);
973
          final Widget header = new _DatePickerHeader(
974 975 976
            selectedDate: _selectedDate,
            mode: _mode,
            onModeChanged: _handleModeChanged,
977
            orientation: orientation,
978 979 980 981 982 983 984 985
          );
          switch (orientation) {
            case Orientation.portrait:
              return new SizedBox(
                width: _kMonthPickerPortraitWidth,
                child: new Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
986 987 988 989 990 991 992 993 994 995 996 997 998 999
                  children: <Widget>[
                    header,
                    new Container(
                      color: theme.dialogBackgroundColor,
                      child: new Column(
                        mainAxisSize: MainAxisSize.min,
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: <Widget>[
                          picker,
                          actions,
                        ],
                      ),
                    ),
                  ],
1000
                ),
1001 1002 1003 1004 1005 1006 1007 1008 1009
              );
            case Orientation.landscape:
              return new SizedBox(
                height: _kDatePickerLandscapeHeight,
                child: new Row(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    header,
1010
                    new Flexible(
1011
                      child: new Container(
1012
                        width: _kMonthPickerLandscapeWidth,
1013
                        color: theme.dialogBackgroundColor,
1014 1015 1016
                        child: new Column(
                          mainAxisSize: MainAxisSize.min,
                          crossAxisAlignment: CrossAxisAlignment.stretch,
1017 1018 1019 1020 1021 1022
                          children: <Widget>[picker, actions],
                        ),
                      ),
                    ),
                  ],
                ),
1023 1024 1025 1026
              );
          }
          return null;
        }
1027 1028
      )
    );
1029 1030 1031 1032 1033 1034 1035

    return new Theme(
      data: theme.copyWith(
        dialogBackgroundColor: Colors.transparent,
      ),
      child: dialog,
    );
1036 1037 1038
  }
}

1039 1040 1041
/// Signature for predicating dates for enabled date selections.
///
/// See [showDatePicker].
1042
typedef bool SelectableDayPredicate(DateTime day);
1043

1044 1045 1046
/// Shows a dialog containing a material design date picker.
///
/// The returned [Future] resolves to the date selected by the user when the
1047
/// user closes the dialog. If the user cancels the dialog, null is returned.
1048
///
1049 1050 1051 1052
/// 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.
///
1053 1054
/// 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
1055
/// to month+day, and must not be null.
1056
///
Yegor's avatar
Yegor committed
1057 1058 1059 1060 1061 1062 1063 1064
/// 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
1065 1066 1067
/// The `context` argument is passed to [showDialog], the documentation for
/// which discusses how it is used.
///
1068 1069 1070
/// See also:
///
///  * [showTimePicker]
1071
///  * <https://material.google.com/components/pickers.html#pickers-date-pickers>
1072
Future<DateTime> showDatePicker({
1073 1074 1075
  @required BuildContext context,
  @required DateTime initialDate,
  @required DateTime firstDate,
1076
  @required DateTime lastDate,
1077
  SelectableDayPredicate selectableDayPredicate,
1078
  DatePickerMode initialDatePickerMode = DatePickerMode.day,
Yegor's avatar
Yegor committed
1079 1080
  Locale locale,
  TextDirection textDirection,
1081
}) async {
1082 1083 1084 1085 1086 1087 1088
  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'
  );
1089
  assert(initialDatePickerMode != null, 'initialDatePickerMode must not be null');
Yegor's avatar
Yegor committed
1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114

  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>(
1115
    context: context,
1116
    builder: (BuildContext context) => child,
1117 1118
  );
}