// 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'; import 'dart:math' as math; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:intl/date_symbols.dart'; import 'package:intl/intl.dart'; import 'package:meta/meta.dart'; import 'button_bar.dart'; import 'button.dart'; import 'colors.dart'; import 'debug.dart'; import 'dialog.dart'; import 'flat_button.dart'; import 'icon_button.dart'; import 'icon.dart'; import 'icons.dart'; import 'ink_well.dart'; import 'theme.dart'; import 'typography.dart'; enum _DatePickerMode { day, year } const double _kDatePickerHeaderPortraitHeight = 100.0; const double _kDatePickerHeaderLandscapeWidth = 168.0; 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); const double _kMonthPickerPortraitWidth = 330.0; const double _kMonthPickerLandscapeWidth = 344.0; const double _kDialogActionBarHeight = 52.0; const double _kDatePickerLandscapeHeight = _kMaxDayPickerHeight + _kDialogActionBarHeight; // Shows the selected date in large font and toggles between year and day mode class _DatePickerHeader extends StatelessWidget { _DatePickerHeader({ Key key, @required this.selectedDate, @required this.mode, @required this.onModeChanged, @required this.orientation, }) : super(key: key) { assert(selectedDate != null); assert(mode != null); assert(orientation != null); } final DateTime selectedDate; final _DatePickerMode mode; final ValueChanged<_DatePickerMode> onModeChanged; final Orientation orientation; void _handleChangeMode(_DatePickerMode value) { if (value != mode) onModeChanged(value); } @override Widget build(BuildContext context) { ThemeData themeData = Theme.of(context); TextTheme headerTextTheme = themeData.primaryTextTheme; Color dayColor; Color yearColor; switch(themeData.primaryColorBrightness) { case Brightness.light: dayColor = mode == _DatePickerMode.day ? Colors.black87 : Colors.black54; yearColor = mode == _DatePickerMode.year ? Colors.black87 : Colors.black54; break; case Brightness.dark: dayColor = mode == _DatePickerMode.day ? Colors.white : Colors.white70; yearColor = mode == _DatePickerMode.year ? Colors.white : Colors.white70; break; } TextStyle dayStyle = headerTextTheme.display1.copyWith(color: dayColor, height: 1.4); TextStyle yearStyle = headerTextTheme.subhead.copyWith(color: yearColor, height: 1.4); Color backgroundColor; switch (themeData.brightness) { case Brightness.light: backgroundColor = themeData.primaryColor; break; case Brightness.dark: backgroundColor = themeData.backgroundColor; break; } double width; double height; EdgeInsets padding; MainAxisAlignment mainAxisAlignment; switch (orientation) { case Orientation.portrait: height = _kDatePickerHeaderPortraitHeight; padding = const EdgeInsets.symmetric(horizontal: 24.0); mainAxisAlignment = MainAxisAlignment.center; break; case Orientation.landscape: width = _kDatePickerHeaderLandscapeWidth; padding = const EdgeInsets.all(16.0); mainAxisAlignment = MainAxisAlignment.start; break; } return new Container( width: width, height: height, padding: padding, decoration: new BoxDecoration(backgroundColor: backgroundColor), child: new Column( mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ new GestureDetector( onTap: () => _handleChangeMode(_DatePickerMode.year), child: new Text(new DateFormat('yyyy').format(selectedDate), style: yearStyle) ), new GestureDetector( onTap: () => _handleChangeMode(_DatePickerMode.day), child: new Text(new DateFormat('E, MMM\u00a0d').format(selectedDate), style: dayStyle) ), ] ) ); } } class _DayPickerGridDelegate extends GridDelegateWithInOrderChildPlacement { @override GridSpecification getGridSpecification(BoxConstraints constraints, int childCount) { final int columnCount = DateTime.DAYS_PER_WEEK; return new GridSpecification.fromRegularTiles( tileWidth: constraints.maxWidth / columnCount, tileHeight: math.min(_kDayPickerRowHeight, constraints.maxHeight / (_kMaxDayPickerRowCount + 1)), columnCount: columnCount, rowCount: (childCount / columnCount).ceil() ); } } final _DayPickerGridDelegate _kDayPickerGridDelegate = new _DayPickerGridDelegate(); /// 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. /// /// The day picker widget is rarely used directly. Instead, consider using /// [showDatePicker], which creates a date picker dialog. /// /// See also: /// /// * [showDatePicker]. /// * <https://material.google.com/components/pickers.html#pickers-date-pickers> class DayPicker extends StatelessWidget { /// Creates a day picker. /// /// Rarely used directly. Instead, typically used as part of a [DatePicker]. DayPicker({ Key key, @required this.selectedDate, @required this.currentDate, @required this.onChanged, @required this.displayedMonth }) : super(key: key) { assert(selectedDate != null); assert(currentDate != null); assert(onChanged != null); assert(displayedMonth != null); } /// The currently selected date. /// /// This date is highlighted in the picker. final DateTime selectedDate; /// The current date at the time the picker is displayed. final DateTime currentDate; /// Called when the user picks a day. final ValueChanged<DateTime> onChanged; /// The month whose days are displayed by this picker. final DateTime displayedMonth; List<Widget> _getDayHeaders(TextStyle headerStyle) { final DateFormat dateFormat = new DateFormat(); final DateSymbols symbols = dateFormat.dateSymbols; return symbols.NARROWWEEKDAYS.map((String weekDay) { return new Center(child: new Text(weekDay, style: headerStyle)); }).toList(growable: false); } @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); final int year = displayedMonth.year; final int month = displayedMonth.month; // Dart's Date time constructor is very forgiving and will understand // month 13 as January of the next year. :) final int daysInMonth = new DateTime(year, month + 1).difference(new DateTime(year, month)).inDays; // This assumes a start day of SUNDAY, but could be changed. final int firstWeekday = new DateTime(year, month).weekday % 7; final List<Widget> labels = <Widget>[]; labels.addAll(_getDayHeaders(themeData.textTheme.caption)); for (int i = 0; true; ++i) { final int day = i - firstWeekday + 1; if (day > daysInMonth) break; if (day < 1) { labels.add(new Container()); } else { BoxDecoration decoration; TextStyle itemStyle = themeData.textTheme.body1; if (selectedDate.year == year && selectedDate.month == month && selectedDate.day == day) { // The selected day gets a circle background highlight, and a contrasting text color. itemStyle = themeData.accentTextTheme.body2; decoration = new BoxDecoration( backgroundColor: themeData.accentColor, shape: BoxShape.circle ); } else if (currentDate.year == year && currentDate.month == month && currentDate.day == day) { // The current day gets a different text color. itemStyle = themeData.textTheme.body2.copyWith(color: themeData.accentColor); } labels.add(new GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { DateTime result = new DateTime(year, month, day); onChanged(result); }, child: new Container( decoration: decoration, child: new Center( child: new Text(day.toString(), style: itemStyle) ) ) )); } } return new Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: new Column( children: <Widget>[ new Container( height: _kDayPickerRowHeight, child: new Center( child: new Text(new DateFormat('yMMMM').format(displayedMonth), style: themeData.textTheme.subhead ) ) ), new Flexible( fit: FlexFit.loose, child: new CustomGrid( delegate: _kDayPickerGridDelegate, children: labels ) ) ] ) ); } } /// 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. /// /// The month picker widget is rarely used directly. Instead, consider using /// [showDatePicker], which creates a date picker dialog. /// /// See also: /// /// * [showDatePicker] /// * <https://material.google.com/components/pickers.html#pickers-date-pickers> class MonthPicker extends StatefulWidget { /// Creates a month picker. /// /// Rarely used directly. Instead, typically used as part of a [DatePicker]. MonthPicker({ Key key, @required this.selectedDate, @required this.onChanged, @required this.firstDate, @required this.lastDate }) : super(key: key) { assert(selectedDate != null); assert(onChanged != null); assert(lastDate.isAfter(firstDate)); assert(selectedDate.isAfter(firstDate) || selectedDate.isAtSameMomentAs(firstDate)); } /// The currently selected date. /// /// This date is highlighted in the picker. final DateTime selectedDate; /// Called when the user picks a month. final ValueChanged<DateTime> onChanged; /// The earliest date the user is permitted to pick. final DateTime firstDate; /// The latest date the user is permitted to pick. final DateTime lastDate; @override _MonthPickerState createState() => new _MonthPickerState(); } class _MonthPickerState extends State<MonthPicker> { @override void initState() { super.initState(); _updateCurrentDate(); } @override void didUpdateConfig(MonthPicker oldConfig) { if (config.selectedDate != oldConfig.selectedDate) _dayPickerListKey = new GlobalKey<ScrollableState>(); } DateTime _currentDate; Timer _timer; GlobalKey<ScrollableState> _dayPickerListKey = new GlobalKey<ScrollableState>(); void _updateCurrentDate() { _currentDate = new DateTime.now(); DateTime tomorrow = new DateTime(_currentDate.year, _currentDate.month, _currentDate.day + 1); Duration timeUntilTomorrow = tomorrow.difference(_currentDate); timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding if (_timer != null) _timer.cancel(); _timer = new Timer(timeUntilTomorrow, () { setState(() { _updateCurrentDate(); }); }); } int _monthDelta(DateTime startDate, DateTime endDate) { return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month; } List<Widget> _buildItems(BuildContext context, int start, int count) { final List<Widget> result = new List<Widget>(); final DateTime startDate = new DateTime(config.firstDate.year + start ~/ 12, config.firstDate.month + start % 12); for (int i = 0; i < count; ++i) { DateTime displayedMonth = new DateTime(startDate.year + i ~/ 12, startDate.month + i % 12); result.add(new DayPicker( key: new ValueKey<DateTime>(displayedMonth), selectedDate: config.selectedDate, currentDate: _currentDate, onChanged: config.onChanged, displayedMonth: displayedMonth )); } return result; } void _handleNextMonth() { ScrollableState state = _dayPickerListKey.currentState; state?.scrollTo(state.scrollOffset.round() + 1.0, duration: _kMonthScrollDuration); } void _handlePreviousMonth() { ScrollableState state = _dayPickerListKey.currentState; state?.scrollTo(state.scrollOffset.round() - 1.0, duration: _kMonthScrollDuration); } @override Widget build(BuildContext context) { return new SizedBox( width: _kMonthPickerPortraitWidth, height: _kMaxDayPickerHeight, child: new Stack( children: <Widget>[ new PageableLazyList( key: _dayPickerListKey, initialScrollOffset: _monthDelta(config.firstDate, config.selectedDate).toDouble(), scrollDirection: Axis.horizontal, itemCount: _monthDelta(config.firstDate, config.lastDate) + 1, itemBuilder: _buildItems ), new Positioned( top: 0.0, left: 8.0, child: new IconButton( icon: new Icon(Icons.chevron_left), tooltip: 'Previous month', onPressed: _handlePreviousMonth ) ), new Positioned( top: 0.0, right: 8.0, child: new IconButton( icon: new Icon(Icons.chevron_right), tooltip: 'Next month', onPressed: _handleNextMonth ) ) ] ) ); } @override void dispose() { if (_timer != null) _timer.cancel(); super.dispose(); } } /// A scrollable list of years to allow picking a year. /// /// The year picker widget is rarely used directly. Instead, consider using /// [showDatePicker], which creates a date picker dialog. /// /// Requires one of its ancestors to be a [Material] widget. /// /// See also: /// /// * [showDatePicker] /// * <https://material.google.com/components/pickers.html#pickers-date-pickers> class YearPicker extends StatefulWidget { /// Creates a year picker. /// /// The [selectedDate] and [onChanged] arguments must not be null. The /// [lastDate] must be after the [firstDate]. /// /// Rarely used directly. Instead, typically used as part of a [DatePicker]. YearPicker({ Key key, @required this.selectedDate, @required this.onChanged, @required this.firstDate, @required this.lastDate }) : super(key: key) { assert(selectedDate != null); assert(onChanged != null); assert(lastDate.isAfter(firstDate)); } /// The currently selected date. /// /// This date is highlighted in the picker. final DateTime selectedDate; /// Called when the user picks a year. final ValueChanged<DateTime> onChanged; /// The earliest date the user is permitted to pick. final DateTime firstDate; /// The latest date the user is permitted to pick. final DateTime lastDate; @override _YearPickerState createState() => new _YearPickerState(); } class _YearPickerState extends State<YearPicker> { static const double _itemExtent = 50.0; List<Widget> _buildItems(BuildContext context, int start, int count) { final ThemeData themeData = Theme.of(context); final TextStyle style = themeData.textTheme.body1; final List<Widget> items = new List<Widget>(); for (int i = start; i < start + count; i++) { final int year = config.firstDate.year + i; final TextStyle itemStyle = year == config.selectedDate.year ? themeData.textTheme.headline.copyWith(color: themeData.accentColor) : style; items.add(new InkWell( key: new ValueKey<int>(year), onTap: () { config.onChanged(new DateTime(year, config.selectedDate.month, config.selectedDate.day)); }, child: new Center( child: new Text(year.toString(), style: itemStyle) ) )); } return items; } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); return new ScrollableLazyList( itemExtent: _itemExtent, itemCount: config.lastDate.year - config.firstDate.year + 1, itemBuilder: _buildItems ); } } class _DatePickerDialog extends StatefulWidget { _DatePickerDialog({ Key key, this.initialDate, this.firstDate, this.lastDate }) : super(key: key); final DateTime initialDate; final DateTime firstDate; final DateTime lastDate; @override _DatePickerDialogState createState() => new _DatePickerDialogState(); } class _DatePickerDialogState extends State<_DatePickerDialog> { @override void initState() { super.initState(); _selectedDate = config.initialDate; } DateTime _selectedDate; _DatePickerMode _mode = _DatePickerMode.day; GlobalKey _pickerKey = new GlobalKey(); void _handleModeChanged(_DatePickerMode mode) { HapticFeedback.vibrate(); setState(() { _mode = mode; }); } void _handleYearChanged(DateTime value) { HapticFeedback.vibrate(); setState(() { _mode = _DatePickerMode.day; _selectedDate = value; }); } void _handleDayChanged(DateTime value) { HapticFeedback.vibrate(); setState(() { _selectedDate = value; }); } void _handleCancel() { Navigator.pop(context); } void _handleOk() { Navigator.pop(context, _selectedDate); } Widget _buildPicker() { assert(_mode != null); switch (_mode) { case _DatePickerMode.day: return new MonthPicker( key: _pickerKey, selectedDate: _selectedDate, onChanged: _handleDayChanged, firstDate: config.firstDate, lastDate: config.lastDate ); case _DatePickerMode.year: return new YearPicker( key: _pickerKey, selectedDate: _selectedDate, onChanged: _handleYearChanged, firstDate: config.firstDate, lastDate: config.lastDate ); } return null; } @override Widget build(BuildContext context) { Widget picker = new Flexible( fit: FlexFit.loose, child: new SizedBox( height: _kMaxDayPickerHeight, child: _buildPicker(), ) ); 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 _DatePickerHeader( selectedDate: _selectedDate, mode: _mode, onModeChanged: _handleModeChanged, orientation: orientation ); assert(orientation != null); switch (orientation) { case Orientation.portrait: return new SizedBox( width: _kMonthPickerPortraitWidth, child: new Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[header, picker, actions] ) ); case Orientation.landscape: return new SizedBox( height: _kDatePickerLandscapeHeight, child: new Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[ header, new Flexible( fit: FlexFit.loose, child: new SizedBox( width: _kMonthPickerLandscapeWidth, child: new Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[picker, actions] ) ) ) ] ) ); } return null; } ) ); } } /// Shows a dialog containing a material design date picker. /// /// The returned [Future] resolves to the date selected by the user when the /// user closes the dialog. If the user cancels the dialog, null is returned. /// /// See also: /// /// * [showTimePicker] /// * <https://material.google.com/components/pickers.html#pickers-date-pickers> Future<DateTime> showDatePicker({ BuildContext context, DateTime initialDate, DateTime firstDate, DateTime lastDate }) async { return await showDialog( context: context, child: new _DatePickerDialog( initialDate: initialDate, firstDate: firstDate, lastDate: lastDate ) ); }