From 4560ebcf9fa5b33d9bcb8a79738cab585b7a8ba6 Mon Sep 17 00:00:00 2001 From: Darren Austin Date: Thu, 7 May 2020 17:54:03 -0700 Subject: [PATCH] Implementation of the Material Date Range Picker. (#55939) --- .../pickers/calendar_date_range_picker.dart | 773 ++++++++++++++++++ .../material/pickers/date_picker_common.dart | 53 +- .../material/pickers/date_picker_dialog.dart | 56 +- .../material/pickers/date_picker_header.dart | 14 +- .../pickers/date_range_picker_dialog.dart | 715 ++++++++++++++++ .../lib/src/material/pickers/date_utils.dart | 35 + .../material/pickers/input_date_picker.dart | 76 +- .../pickers/input_date_range_picker.dart | 276 +++++++ .../lib/src/material/pickers/pickers.dart | 7 +- .../test/material/date_range_picker_test.dart | 412 ++++++++++ 10 files changed, 2354 insertions(+), 63 deletions(-) create mode 100644 packages/flutter/lib/src/material/pickers/calendar_date_range_picker.dart create mode 100644 packages/flutter/lib/src/material/pickers/date_range_picker_dialog.dart create mode 100644 packages/flutter/lib/src/material/pickers/input_date_range_picker.dart create mode 100644 packages/flutter/test/material/date_range_picker_test.dart diff --git a/packages/flutter/lib/src/material/pickers/calendar_date_range_picker.dart b/packages/flutter/lib/src/material/pickers/calendar_date_range_picker.dart new file mode 100644 index 0000000000..a9a9a31a6a --- /dev/null +++ b/packages/flutter/lib/src/material/pickers/calendar_date_range_picker.dart @@ -0,0 +1,773 @@ +// Copyright 2014 The Flutter 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:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../color_scheme.dart'; +import '../divider.dart'; +import '../material_localizations.dart'; +import '../text_theme.dart'; +import '../theme.dart'; + +import 'date_utils.dart' as utils; + +const double _monthItemHeaderHeight = 58.0; +const double _monthItemFooterHeight = 12.0; +const double _monthItemRowHeight = 42.0; +const double _monthItemSpaceBetweenRows = 8.0; +const double _horizontalPadding = 8.0; +const double _maxCalendarWidthLandscape = 384.0; +const double _maxCalendarWidthPortrait = 480.0; + +/// Displays a scrollable calendar grid that allows a user to select a range +/// of dates. +/// +/// Note: this is not publicly exported (see pickers.dart), as it is an +/// internal component used by [showDateRangePicker]. +class CalendarDateRangePicker extends StatefulWidget { + /// Creates a scrollable calendar grid for picking date ranges. + CalendarDateRangePicker({ + Key key, + DateTime initialStartDate, + DateTime initialEndDate, + @required DateTime firstDate, + @required DateTime lastDate, + DateTime currentDate, + @required this.onStartDateChanged, + @required this.onEndDateChanged, + }) : initialStartDate = initialStartDate != null ? utils.dateOnly(initialStartDate) : null, + initialEndDate = initialEndDate != null ? utils.dateOnly(initialEndDate) : null, + assert(firstDate != null), + assert(lastDate != null), + firstDate = utils.dateOnly(firstDate), + lastDate = utils.dateOnly(lastDate), + currentDate = utils.dateOnly(currentDate ?? DateTime.now()), + super(key: key) { + assert( + this.initialStartDate == null || this.initialEndDate == null || !this.initialStartDate.isAfter(initialEndDate), + 'initialStartDate must be on or before initialEndDate.' + ); + assert( + !this.lastDate.isBefore(this.firstDate), + 'firstDate must be on or before lastDate.' + ); + } + + /// The [DateTime] that represents the start of the initial date range selection. + final DateTime initialStartDate; + + /// The [DateTime] that represents the end of the initial date range selection. + final DateTime initialEndDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// The [DateTime] representing today. It will be highlighted in the day grid. + final DateTime currentDate; + + /// Called when the user changes the start date of the selected range. + final ValueChanged onStartDateChanged; + + /// Called when the user changes the end date of the selected range. + final ValueChanged onEndDateChanged; + + @override + _CalendarDateRangePickerState createState() => _CalendarDateRangePickerState(); +} + +class _CalendarDateRangePickerState extends State { + DateTime _startDate; + DateTime _endDate; + int _initialMonthIndex = 0; + ScrollController _controller; + bool _showWeekBottomDivider; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _controller.addListener(_scrollListener); + + _startDate = widget.initialStartDate; + _endDate = widget.initialEndDate; + + // Calculate the index for the initially displayed month. This is needed to + // divide the list of months into two `SliverList`s. + final DateTime initialDate = widget.initialStartDate ?? widget.currentDate; + if (widget.firstDate.isBefore(initialDate) && + widget.lastDate.isAfter(initialDate)) { + _initialMonthIndex = utils.monthDelta(widget.firstDate, initialDate); + } + + _showWeekBottomDivider = _initialMonthIndex != 0; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _scrollListener() { + if (_controller.offset <= _controller.position.minScrollExtent) { + setState(() { + _showWeekBottomDivider = false; + }); + } else if (!_showWeekBottomDivider) { + setState(() { + _showWeekBottomDivider = true; + }); + } + } + + int get _numberOfMonths => utils.monthDelta(widget.firstDate, widget.lastDate) + 1; + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + HapticFeedback.vibrate(); + break; + default: + break; + } + } + + // This updates the selected date range using this logic: + // + // * From the unselected state, selecting one date creates the start date. + // * If the next selection is before the start date, reset date range and + // set the start date to that selection. + // * If the next selection is on or after the start date, set the end date + // to that selection. + // * After both start and end dates are selected, any subsequent selection + // resets the date range and sets start date to that selection. + void _updateSelection(DateTime date) { + _vibrate(); + setState(() { + if (_startDate != null && _endDate == null && !date.isBefore(_startDate)) { + _endDate = date; + widget.onEndDateChanged?.call(_endDate); + } else { + _startDate = date; + widget.onStartDateChanged?.call(_startDate); + if (_endDate != null) { + _endDate = null; + widget.onEndDateChanged?.call(_endDate); + } + } + }); + } + + Widget _buildMonthItem(BuildContext context, int index, bool beforeInitialMonth) { + final int monthIndex = beforeInitialMonth + ? _initialMonthIndex - index - 1 + : _initialMonthIndex + index; + final DateTime month = utils.addMonthsToMonthDate(widget.firstDate, monthIndex); + return _MonthItem( + selectedDateStart: _startDate, + selectedDateEnd: _endDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + onChanged: _updateSelection, + ); + } + + @override + Widget build(BuildContext context) { + const Key sliverAfterKey = Key('sliverAfterKey'); + + return Column( + children: [ + _DayHeaders(), + if (_showWeekBottomDivider) const Divider(height: 0), + Expanded( + // In order to prevent performance issues when displaying the + // correct initial month, 2 `SliverList`s are used to split the + // months. The first item in the second SliverList is the initial + // month to be displayed. + child: CustomScrollView( + controller: _controller, + center: sliverAfterKey, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate((BuildContext context, int index) => _buildMonthItem(context, index, true), + childCount: _initialMonthIndex, + ), + ), + SliverList( + key: sliverAfterKey, + delegate: SliverChildBuilderDelegate((BuildContext context, int index) => _buildMonthItem(context, index, false), + childCount: _numberOfMonths - _initialMonthIndex, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _DayHeaders extends StatelessWidget { + /// 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 _getDayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) { + final List result = []; + for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { + final String weekday = localizations.narrowWeekdays[i]; + result.add(ExcludeSemantics( + child: Center(child: Text(weekday, style: headerStyle)), + )); + if (i == (localizations.firstDayOfWeekIndex - 1) % 7) + break; + } + return result; + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final ColorScheme colorScheme = themeData.colorScheme; + final TextStyle textStyle = themeData.textTheme.subtitle2.apply(color: colorScheme.onSurface); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final List labels = _getDayHeaders(textStyle, localizations); + + // Add leading and trailing containers for edges of the custom grid layout. + labels.insert(0, Container()); + labels.add(Container()); + + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).orientation == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait, + maxHeight: _monthItemRowHeight, + ), + child: GridView.custom( + shrinkWrap: true, + gridDelegate: _monthItemGridDelegate, + childrenDelegate: SliverChildListDelegate( + labels, + addRepaintBoundaries: false, + ), + ), + ); + } +} + +class _MonthItemGridDelegate extends SliverGridDelegate { + const _MonthItemGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double tileWidth = (constraints.crossAxisExtent - 2 * _horizontalPadding) / DateTime.daysPerWeek; + return _MonthSliverGridLayout( + crossAxisCount: DateTime.daysPerWeek + 2, + dayChildWidth: tileWidth, + edgeChildWidth: _horizontalPadding, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_MonthItemGridDelegate oldDelegate) => false; +} + +const _MonthItemGridDelegate _monthItemGridDelegate = _MonthItemGridDelegate(); + +class _MonthSliverGridLayout extends SliverGridLayout { + /// Creates a layout that uses equally sized and spaced tiles for each day of + /// the week and an additional edge tile for padding at the start and end of + /// each row. + /// + /// This is necessary to facilitate the painting of the range highlight + /// correctly. + const _MonthSliverGridLayout({ + @required this.crossAxisCount, + @required this.dayChildWidth, + @required this.edgeChildWidth, + @required this.reverseCrossAxis, + }) : assert(crossAxisCount != null && crossAxisCount > 0), + assert(dayChildWidth != null && dayChildWidth >= 0), + assert(edgeChildWidth != null && edgeChildWidth >= 0), + assert(reverseCrossAxis != null); + + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The width in logical pixels of the day child widgets. + final double dayChildWidth; + + /// The width in logical pixels of the edge child widgets. + final double edgeChildWidth; + + /// Whether the children should be placed in the opposite order of increasing + /// coordinates in the cross axis. + /// + /// For example, if the cross axis is horizontal, the children are placed from + /// left to right when [reverseCrossAxis] is false and from right to left when + /// [reverseCrossAxis] is true. + /// + /// Typically set to the return value of [axisDirectionIsReversed] applied to + /// the [SliverConstraints.crossAxisDirection]. + final bool reverseCrossAxis; + + /// The number of logical pixels from the leading edge of one row to the + /// leading edge of the next row. + double get _rowHeight { + return _monthItemRowHeight + _monthItemSpaceBetweenRows; + } + + /// The height in logical pixels of the children widgets. + double get _childHeight { + return _monthItemRowHeight; + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + return crossAxisCount * (scrollOffset ~/ _rowHeight); + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + final int mainAxisCount = (scrollOffset / _rowHeight).ceil(); + return math.max(0, crossAxisCount * mainAxisCount - 1); + } + + double _getCrossAxisOffset(double crossAxisStart, bool isPadding) { + if (reverseCrossAxis) { + return + ((crossAxisCount - 2) * dayChildWidth + 2 * edgeChildWidth) - + crossAxisStart - + (isPadding ? edgeChildWidth : dayChildWidth); + } + return crossAxisStart; + } + + @override + SliverGridGeometry getGeometryForChildIndex(int index) { + final int adjustedIndex = index % crossAxisCount; + final bool isEdge = adjustedIndex == 0 || adjustedIndex == crossAxisCount - 1; + final double crossAxisStart = math.max(0, (adjustedIndex - 1) * dayChildWidth + edgeChildWidth); + + return SliverGridGeometry( + scrollOffset: (index ~/ crossAxisCount) * _rowHeight, + crossAxisOffset: _getCrossAxisOffset(crossAxisStart, isEdge), + mainAxisExtent: _childHeight, + crossAxisExtent: isEdge ? edgeChildWidth : dayChildWidth, + ); + } + + @override + double computeMaxScrollOffset(int childCount) { + assert(childCount >= 0); + final int mainAxisCount = ((childCount - 1) ~/ crossAxisCount) + 1; + final double mainAxisSpacing = _rowHeight - _childHeight; + return _rowHeight * mainAxisCount - mainAxisSpacing; + } +} + +/// Displays the days of a given month and allows choosing a date range. +/// +/// The days are arranged in a rectangular grid with one column for each day of +/// the week. +class _MonthItem extends StatelessWidget { + /// Creates a month item. + _MonthItem({ + Key key, + @required this.selectedDateStart, + @required this.selectedDateEnd, + @required this.currentDate, + @required this.onChanged, + @required this.firstDate, + @required this.lastDate, + @required this.displayedMonth, + this.dragStartBehavior = DragStartBehavior.start, + }) : assert(firstDate != null), + assert(lastDate != null), + assert(!firstDate.isAfter(lastDate)), + assert(selectedDateStart == null || !selectedDateStart.isBefore(firstDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)), + assert(selectedDateStart == null || !selectedDateStart.isAfter(lastDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isAfter(lastDate)), + assert(selectedDateStart == null || selectedDateEnd == null || !selectedDateStart.isAfter(selectedDateEnd)), + assert(currentDate != null), + assert(onChanged != null), + assert(displayedMonth != null), + assert(dragStartBehavior != null), + super(key: key); + + /// The currently selected start date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDateStart; + + /// The currently selected end date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDateEnd; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged 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; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag gesture used to scroll a + /// date picker wheel will begin upon the detection of a drag gesture. If set + /// to [DragStartBehavior.down] it will begin when a down event is first + /// detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for + /// the different behaviors. + final DragStartBehavior dragStartBehavior; + + Color _highlightColor(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + return Color.alphaBlend(colors.primary.withOpacity(0.12), colors.background); + } + + Widget _buildDayItem(BuildContext context, DateTime dayToBuild, int firstDayOffset, int daysInMonth) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final TextTheme textTheme = theme.textTheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TextDirection textDirection = Directionality.of(context); + final Color highlightColor = _highlightColor(context); + final int day = dayToBuild.day; + + final bool isDisabled = dayToBuild.isAfter(lastDate) || dayToBuild.isBefore(firstDate); + + BoxDecoration decoration; + TextStyle itemStyle = textTheme.bodyText2; + + final bool isRangeSelected = selectedDateStart != null && selectedDateEnd != null; + final bool isSelectedDayStart = selectedDateStart != null && dayToBuild.isAtSameMomentAs(selectedDateStart); + final bool isSelectedDayEnd = selectedDateEnd != null && dayToBuild.isAtSameMomentAs(selectedDateEnd); + final bool isInRange = isRangeSelected && + dayToBuild.isAfter(selectedDateStart) && + dayToBuild.isBefore(selectedDateEnd); + + _HighlightPainter highlightPainter; + + if (isSelectedDayStart || isSelectedDayEnd) { + // The selected start and end dates gets a circle background + // highlight, and a contrasting text color. + itemStyle = textTheme.bodyText2?.apply(color: colorScheme.onPrimary); + decoration = BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ); + + if (isRangeSelected && selectedDateStart != selectedDateEnd) { + final _HighlightPainterStyle style = isSelectedDayStart + ? _HighlightPainterStyle.highlightTrailing + : _HighlightPainterStyle.highlightLeading; + highlightPainter = _HighlightPainter( + color: highlightColor, + style: style, + textDirection: textDirection, + ); + } + } else if (isInRange) { + // The days within the range get a light background highlight. + highlightPainter = _HighlightPainter( + color: highlightColor, + style: _HighlightPainterStyle.highlightAll, + textDirection: textDirection, + ); + } else if (isDisabled) { + itemStyle = textTheme.bodyText2?.apply(color: colorScheme.onSurface.withOpacity(0.38)); + } else if (utils.isSameDay(currentDate, dayToBuild)) { + // The current day gets a different text color and a circle stroke + // border. + itemStyle = textTheme.bodyText2?.apply(color: colorScheme.primary); + decoration = BoxDecoration( + border: Border.all(color: colorScheme.primary, width: 1), + shape: BoxShape.circle, + ); + } + + // 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. + String semanticLabel = '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}'; + if (isSelectedDayStart) { + // TODO(darrenaustin): localize 'Start Date' and 'End Date' + semanticLabel = 'Start Date ' + semanticLabel; + } else if (isSelectedDayEnd) { + semanticLabel = 'End Date ' + semanticLabel; + } + + Widget dayWidget = Container( + decoration: decoration, + child: Center( + child: Semantics( + label: semanticLabel, + selected: isSelectedDayStart || isSelectedDayEnd, + child: ExcludeSemantics( + child: Text(localizations.formatDecimal(day), style: itemStyle), + ), + ), + ), + ); + + if (highlightPainter != null) { + dayWidget = CustomPaint( + painter: highlightPainter, + child: dayWidget, + ); + } + + if (!isDisabled) { + dayWidget = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { onChanged(dayToBuild); }, + child: dayWidget, + dragStartBehavior: dragStartBehavior, + ); + } + + return dayWidget; + } + + Widget _buildEdgeContainer(BuildContext context, bool isHighlighted) { + return Container(color: isHighlighted ? _highlightColor(context) : null); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final int year = displayedMonth.year; + final int month = displayedMonth.month; + final int daysInMonth = utils.getDaysInMonth(year, month); + final int dayOffset = utils.firstDayOffset(year, month, localizations); + final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); + final double gridHeight = + weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows; + final List dayItems = []; + + 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 - dayOffset + 1; + if (day > daysInMonth) + break; + if (day < 1) { + dayItems.add(Container()); + } else { + final DateTime dayToBuild = DateTime(year, month, day); + final Widget dayItem = _buildDayItem( + context, + dayToBuild, + dayOffset, + daysInMonth, + ); + dayItems.add(dayItem); + } + } + + // Add the leading/trailing edge containers to each week in order to + // correctly extend the range highlight. + final List paddedDayItems = []; + for (int i = 0; i < weeks; i++) { + final int start = i * DateTime.daysPerWeek; + final int end = math.min( + start + DateTime.daysPerWeek, + dayItems.length, + ); + final List weekList = dayItems.sublist(start, end); + + final DateTime dateAfterLeadingPadding = DateTime(year, month, start - dayOffset + 1); + // Only color the edge container if it is after the start date and + // on/before the end date. + final bool isLeadingInRange = + !(dayOffset > 0 && i == 0) && + selectedDateStart != null && + selectedDateEnd != null && + dateAfterLeadingPadding.isAfter(selectedDateStart) && + !dateAfterLeadingPadding.isAfter(selectedDateEnd); + weekList.insert(0, _buildEdgeContainer(context, isLeadingInRange)); + + // Only add a trailing edge container if it is for a full week and not a + // partial week. + if (end < dayItems.length || (end == dayItems.length && dayItems.length % DateTime.daysPerWeek == 0)) { + final DateTime dateBeforeTrailingPadding = + DateTime(year, month, end - dayOffset); + // Only color the edge container if it is on/after the start date and + // before the end date. + final bool isTrailingInRange = + selectedDateStart != null && + selectedDateEnd != null && + !dateBeforeTrailingPadding.isBefore(selectedDateStart) && + dateBeforeTrailingPadding.isBefore(selectedDateEnd); + weekList.add(_buildEdgeContainer(context, isTrailingInRange)); + } + + paddedDayItems.addAll(weekList); + } + + final double maxWidth = MediaQuery.of(context).orientation == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait; + return Column( + children: [ + Container( + constraints: BoxConstraints(maxWidth: maxWidth), + height: _monthItemHeaderHeight, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: AlignmentDirectional.centerStart, + child: ExcludeSemantics( + child: Text( + localizations.formatMonthYear(displayedMonth), + style: textTheme.bodyText2.apply(color: themeData.colorScheme.onSurface), + ), + ), + ), + Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: gridHeight, + ), + child: GridView.custom( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: _monthItemGridDelegate, + childrenDelegate: SliverChildListDelegate( + paddedDayItems, + addRepaintBoundaries: false, + ), + ), + ), + const SizedBox(height: _monthItemFooterHeight), + ], + ); + } +} + +/// Determines which style to use to paint the highlight. +enum _HighlightPainterStyle { + /// Paints nothing. + none, + + /// Paints a rectangle that occupies the leading half of the space. + highlightLeading, + + /// Paints a rectangle that occupies the trailing half of the space. + highlightTrailing, + + /// Paints a rectangle that occupies all available space. + highlightAll, +} + +/// This custom painter will add a background highlight to its child. +/// +/// This highlight will be drawn depending on the [style], [color], and +/// [textDirection] supplied. It will either paint a rectangle on the +/// left/right, a full rectangle, or nothing at all. This logic is determined by +/// a combination of the [style] and [textDirection]. +class _HighlightPainter extends CustomPainter { + _HighlightPainter({ + this.color, + this.style = _HighlightPainterStyle.none, + this.textDirection, + }); + + final Color color; + final _HighlightPainterStyle style; + final TextDirection textDirection; + + @override + void paint(Canvas canvas, Size size) { + if (style == _HighlightPainterStyle.none) { + return; + } + + final Paint paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + // This ensures no gaps in the highlight track due to floating point + // division of the available screen width. + final double width = size.width + 1; + final Rect rectLeft = Rect.fromLTWH(0, 0, width / 2, size.height); + final Rect rectRight = Rect.fromLTWH(size.width / 2, 0, width / 2, size.height); + + switch (style) { + case _HighlightPainterStyle.highlightTrailing: + canvas.drawRect( + textDirection == TextDirection.ltr ? rectRight : rectLeft, + paint, + ); + break; + case _HighlightPainterStyle.highlightLeading: + canvas.drawRect( + textDirection == TextDirection.ltr ? rectLeft : rectRight, + paint, + ); + break; + case _HighlightPainterStyle.highlightAll: + canvas.drawRect( + Rect.fromLTWH(0, 0, width, size.height), + paint, + ); + break; + default: + break; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/packages/flutter/lib/src/material/pickers/date_picker_common.dart b/packages/flutter/lib/src/material/pickers/date_picker_common.dart index 06a0015118..d629955385 100644 --- a/packages/flutter/lib/src/material/pickers/date_picker_common.dart +++ b/packages/flutter/lib/src/material/pickers/date_picker_common.dart @@ -2,11 +2,20 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart'; + /// Mode of the date picker dialog. /// /// Either a calendar or text input. In [calendar] mode, a calendar view is /// displayed and the user taps the day they wish to select. In [input] mode a /// [TextField] is displayed and the user types in the date they wish to select. +/// +/// See also: +/// +/// * [showDatePicker] and [showDateRangePicker], which use this to control +/// the initial entry mode of their dialogs. enum DatePickerEntryMode { /// Tapping on a calendar. calendar, @@ -34,5 +43,47 @@ enum DatePickerMode { /// Signature for predicating dates for enabled date selections. /// -/// See [showDatePicker]. +/// See [showDatePicker], which has a [SelectableDayPredicate] parameter used +/// to specify allowable days in the date picker. typedef SelectableDayPredicate = bool Function(DateTime day); + +/// Encapsulates a start and end [DateTime] that represent the range of dates +/// between them. +/// +/// See also: +/// * [showDateRangePicker], which displays a dialog that allows the user to +/// select a date range. +@immutable +class DateTimeRange { + /// Creates a date range for the given start and end [DateTime]. + /// + /// [start] and [end] must be non-null. + const DateTimeRange({ + @required this.start, + @required this.end, + }) : assert(start != null), + assert(end != null); + + /// The start of the range of dates. + final DateTime start; + + /// The end of the range of dates. + final DateTime end; + + /// Returns a [Duration] of the time between [start] and [end]. + /// + /// See [DateTime.difference] for more details. + Duration get duration => end.difference(start); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) + return false; + return other is DateTimeRange + && other.start == start + && other.end == end; + } + + @override + int get hashCode => hashValues(start, end); +} diff --git a/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart b/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart index 48a99afc13..8a66a6e3ef 100644 --- a/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart +++ b/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart @@ -30,6 +30,9 @@ const Size _calendarLandscapeDialogSize = Size(496.0, 346.0); const Size _inputPortraitDialogSize = Size(330.0, 270.0); const Size _inputLandscapeDialogSize = Size(496, 160.0); const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200); +const double _inputFormPortraitHeight = 98.0; +const double _inputFormLandscapeHeight = 108.0; + /// Shows a dialog containing a Material Design date picker. /// @@ -60,17 +63,16 @@ const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200); /// this can be used to only allow weekdays for selection. If provided, it must /// return true for [initialDate]. /// -/// Optional strings for the [cancelText], [confirmText], [errorFormatText], -/// [errorInvalidText], [fieldHintText], [fieldLabelText], and [helpText] allow -/// you to override the default text used for various parts of the dialog: +/// The following optional string parameters allow you to override the default +/// text used for various parts of the dialog: /// +/// * [helpText], label displayed at the top of the dialog. /// * [cancelText], label on the cancel button. /// * [confirmText], label on the ok button. /// * [errorFormatText], message used when the input text isn't in a proper date format. /// * [errorInvalidText], message used when the input text isn't a selectable date. /// * [fieldHintText], text used to prompt the user when no text has been entered in the field. /// * [fieldLabelText], label for the date text input field. -/// * [helpText], label on the top of the dialog. /// /// An optional [locale] argument can be used to set the locale for the date /// picker. It defaults to the ambient locale provided by [Localizations]. @@ -92,6 +94,14 @@ const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200); /// calendar date picker initially appear in the [DatePickerMode.year] or /// [DatePickerMode.day] mode. It defaults to [DatePickerMode.day], and /// must be non-null. +/// +/// See also: +/// +/// * [showDateRangePicker], which shows a material design date range picker +/// used to select a range of dates. +/// * [CalendarDatePicker], which provides the calendar grid used by the date picker dialog. +/// * [InputDatePickerFormField], which provides a text input field for entering dates. +/// Future showDatePicker({ @required BuildContext context, @required DateTime initialDate, @@ -304,7 +314,7 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { Navigator.pop(context); } - void _handelEntryModeToggle() { + void _handleEntryModeToggle() { setState(() { switch (_entryMode) { case DatePickerEntryMode.calendar: @@ -407,18 +417,28 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { picker = Form( key: _formKey, autovalidate: _autoValidate, - child: InputDatePickerFormField( - initialDate: _selectedDate, - firstDate: widget.firstDate, - lastDate: widget.lastDate, - onDateSubmitted: _handleDateChanged, - onDateSaved: _handleDateChanged, - selectableDayPredicate: widget.selectableDayPredicate, - errorFormatText: widget.errorFormatText, - errorInvalidText: widget.errorInvalidText, - fieldHintText: widget.fieldHintText, - fieldLabelText: widget.fieldLabelText, - autofocus: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24), + height: orientation == Orientation.portrait ? _inputFormPortraitHeight : _inputFormLandscapeHeight, + child: Column( + children: [ + const Spacer(), + InputDatePickerFormField( + initialDate: _selectedDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onDateSubmitted: _handleDateChanged, + onDateSaved: _handleDateChanged, + selectableDayPredicate: widget.selectableDayPredicate, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldHintText: widget.fieldHintText, + fieldLabelText: widget.fieldLabelText, + autofocus: true, + ), + const Spacer(), + ], + ), ), ); entryModeIcon = Icons.calendar_today; @@ -436,7 +456,7 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { isShort: orientation == Orientation.landscape, icon: entryModeIcon, iconTooltip: entryModeTooltip, - onIconPressed: _handelEntryModeToggle, + onIconPressed: _handleEntryModeToggle, ); final Size dialogSize = _dialogSize(context) * textScaleFactor; diff --git a/packages/flutter/lib/src/material/pickers/date_picker_header.dart b/packages/flutter/lib/src/material/pickers/date_picker_header.dart index 0f9272abbc..f3f85d0b6a 100644 --- a/packages/flutter/lib/src/material/pickers/date_picker_header.dart +++ b/packages/flutter/lib/src/material/pickers/date_picker_header.dart @@ -25,6 +25,7 @@ const double _headerPaddingLandscape = 16.0; /// /// * Single Date picker with calendar mode. /// * Single Date picker with manual input mode. +/// * Date Range picker with manual input mode. /// /// [helpText], [orientation], [icon], [onIconPressed] are required and must be /// non-null. @@ -112,7 +113,7 @@ class DatePickerHeader extends StatelessWidget { titleText, semanticsLabel: titleSemanticsLabel ?? titleText, style: titleStyle, - maxLines: (isShort || orientation == Orientation.portrait) ? 1 : 2, + maxLines: orientation == Orientation.portrait ? 1 : 2, overflow: TextOverflow.ellipsis, ); final IconButton icon = IconButton( @@ -169,13 +170,14 @@ class DatePickerHeader extends StatelessWidget { child: help, ), SizedBox(height: isShort ? 16 : 56), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: _headerPaddingLandscape, + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _headerPaddingLandscape, + ), + child: title, ), - child: title, ), - const Spacer(), Padding( padding: const EdgeInsets.symmetric( horizontal: 4, diff --git a/packages/flutter/lib/src/material/pickers/date_range_picker_dialog.dart b/packages/flutter/lib/src/material/pickers/date_range_picker_dialog.dart new file mode 100644 index 0000000000..5bb72a9c63 --- /dev/null +++ b/packages/flutter/lib/src/material/pickers/date_range_picker_dialog.dart @@ -0,0 +1,715 @@ +// Copyright 2014 The Flutter 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:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../app_bar.dart'; +import '../back_button.dart'; +import '../button_bar.dart'; +import '../button_theme.dart'; +import '../color_scheme.dart'; +import '../debug.dart'; +import '../dialog.dart'; +import '../dialog_theme.dart'; +import '../flat_button.dart'; +import '../icon_button.dart'; +import '../icons.dart'; +import '../material_localizations.dart'; +import '../scaffold.dart'; +import '../text_theme.dart'; +import '../theme.dart'; + +import 'calendar_date_range_picker.dart'; +import 'date_picker_common.dart'; +import 'date_picker_header.dart'; +import 'date_utils.dart' as utils; +import 'input_date_range_picker.dart'; + +const Size _inputPortraitDialogSize = Size(330.0, 270.0); +const Size _inputLandscapeDialogSize = Size(496, 164.0); +const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200); +const double _inputFormPortraitHeight = 98.0; +const double _inputFormLandscapeHeight = 108.0; + +/// Shows a full screen modal dialog containing a Material Design date range +/// picker. +/// +/// The returned [Future] resolves to the [DateTimeRange] selected by the user +/// when the user saves their selection. If the user cancels the dialog, null is +/// returned. +/// +/// If [initialDateRange] is non-null, then it will be used as the initially +/// selected date range. If it is provided, [initialDateRange.start] must be +/// before or on [initialDateRange.end]. +/// +/// The [firstDate] is the earliest allowable date. The [lastDate] is the latest +/// allowable date. Both must be non-null. +/// +/// If an initial date range is provided, [initialDateRange.start] +/// and [initialDateRange.end] must both fall between or on [firstDate] and +/// [lastDate]. For all of these [DateTime] values, only their dates are +/// considered. Their time fields are ignored. +/// +/// The [currentDate] represents the current day (i.e. today). This +/// date will be highlighted in the day grid. If null, the date of +/// `DateTime.now()` will be used. +/// +/// An optional [initialEntryMode] argument can be used to display the date +/// picker in the [DatePickerEntryMode.calendar] (a scrollable calendar month +/// grid) or [DatePickerEntryMode.input] (two text input fields) mode. +/// It defaults to [DatePickerEntryMode.calendar] and must be non-null. +/// +/// The following optional string parameters allow you to override the default +/// text used for various parts of the dialog: +/// +/// * [helpText], the label displayed at the top of the dialog. +/// * [cancelText], the label on the cancel button for the text input mode. +/// * [confirmText],the label on the ok button for the text input mode. +/// * [saveText], the label on the save button for the fullscreen calendar +/// mode. +/// * [errorFormatText], the message used when an input text isn't in a proper +/// date format. +/// * [errorInvalidText], the message used when an input text isn't a +/// selectable date. +/// * [errorInvalidRangeText], the message used when the date range is +/// invalid (e.g. start date is after end date). +/// * [fieldStartHintText], the text used to prompt the user when no text has +/// been entered in the start field. +/// * [fieldEndHintText], the text used to prompt the user when no text has +/// been entered in the end field. +/// * [fieldStartLabelText], the label for the start date text input field. +/// * [fieldEndLabelText], the label for the end date text input field. +/// +/// 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 +/// ([TextDirection.ltr] or [TextDirection.rtl]) for the date picker. It +/// defaults to the ambient text direction provided by [Directionality]. If both +/// [locale] and [textDirection] are non-null, [textDirection] overrides the +/// direction chosen for the [locale]. +/// +/// The [context], [useRootNavigator] and [routeSettings] arguments are passed +/// to [showDialog], the documentation for which discusses how it is used. +/// [context] and [useRootNavigator] must be non-null. +/// +/// The [builder] parameter can be used to wrap the dialog widget +/// to add inherited widgets like [Theme]. +/// +/// See also: +/// +/// * [showDatePicker], which shows a material design date picker used to +/// select a single date. +/// * [DateTimeRange], which is used to describe a date range. +/// +Future showDateRangePicker({ + @required BuildContext context, + DateTimeRange initialDateRange, + @required DateTime firstDate, + @required DateTime lastDate, + DateTime currentDate, + DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar, + String helpText, + String cancelText, + String confirmText, + String saveText, + String errorFormatText, + String errorInvalidText, + String errorInvalidRangeText, + String fieldStartHintText, + String fieldEndHintText, + String fieldStartLabelText, + String fieldEndLabelText, + Locale locale, + bool useRootNavigator = true, + RouteSettings routeSettings, + TextDirection textDirection, + TransitionBuilder builder, +}) async { + assert(context != null); + assert( + initialDateRange == null || (initialDateRange.start != null && initialDateRange.end != null), + 'initialDateRange must be null or have non-null start and end dates.' + ); + assert( + initialDateRange == null || !initialDateRange.start.isAfter(initialDateRange.end), + 'initialDateRange\'s start date must not be after it\'s end date.' + ); + initialDateRange = initialDateRange == null ? null : utils.datesOnly(initialDateRange); + assert(firstDate != null); + firstDate = utils.dateOnly(firstDate); + assert(lastDate != null); + lastDate = utils.dateOnly(lastDate); + assert( + !lastDate.isBefore(firstDate), + 'lastDate $lastDate must be on or after firstDate $firstDate.' + ); + assert( + initialDateRange == null || !initialDateRange.start.isBefore(firstDate), + 'initialDateRange\'s start date must be on or after firstDate $firstDate.' + ); + assert( + initialDateRange == null || !initialDateRange.end.isBefore(firstDate), + 'initialDateRange\'s end date must be on or after firstDate $firstDate.' + ); + assert( + initialDateRange == null || !initialDateRange.start.isAfter(lastDate), + 'initialDateRange\'s start date must be on or before lastDate $lastDate.' + ); + assert( + initialDateRange == null || !initialDateRange.end.isAfter(lastDate), + 'initialDateRange\'s end date must be on or before lastDate $lastDate.' + ); + currentDate = utils.dateOnly(currentDate ?? DateTime.now()); + assert(initialEntryMode != null); + assert(useRootNavigator != null); + assert(debugCheckHasMaterialLocalizations(context)); + + Widget dialog = _DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + initialEntryMode: initialEntryMode, + helpText: helpText, + cancelText: cancelText, + confirmText: confirmText, + saveText: saveText, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + errorInvalidRangeText: errorInvalidRangeText, + fieldStartHintText: fieldStartHintText, + fieldEndHintText: fieldEndHintText, + fieldStartLabelText: fieldStartLabelText, + fieldEndLabelText: fieldEndLabelText, + ); + + if (textDirection != null) { + dialog = Directionality( + textDirection: textDirection, + child: dialog, + ); + } + + if (locale != null) { + dialog = Localizations.override( + context: context, + locale: locale, + child: dialog, + ); + } + + return showDialog( + context: context, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + useSafeArea: false, + builder: (BuildContext context) { + return builder == null ? dialog : builder(context, dialog); + }, + ); +} + +class _DateRangePickerDialog extends StatefulWidget { + const _DateRangePickerDialog({ + Key key, + this.initialDateRange, + @required this.firstDate, + @required this.lastDate, + this.currentDate, + this.initialEntryMode = DatePickerEntryMode.calendar, + this.helpText, + this.cancelText, + this.confirmText, + this.saveText, + this.errorInvalidRangeText, + this.errorFormatText, + this.errorInvalidText, + this.fieldStartHintText, + this.fieldEndHintText, + this.fieldStartLabelText, + this.fieldEndLabelText, + }) : super(key: key); + + final DateTimeRange initialDateRange; + final DateTime firstDate; + final DateTime lastDate; + final DateTime currentDate; + final DatePickerEntryMode initialEntryMode; + final String cancelText; + final String confirmText; + final String saveText; + final String helpText; + final String errorInvalidRangeText; + final String errorFormatText; + final String errorInvalidText; + final String fieldStartHintText; + final String fieldEndHintText; + final String fieldStartLabelText; + final String fieldEndLabelText; + + @override + _DateRangePickerDialogState createState() => _DateRangePickerDialogState(); +} + +class _DateRangePickerDialogState extends State<_DateRangePickerDialog> { + DatePickerEntryMode _entryMode; + DateTime _selectedStart; + DateTime _selectedEnd; + bool _autoValidate; + final GlobalKey _calendarPickerKey = GlobalKey(); + final GlobalKey _inputPickerKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _selectedStart = widget.initialDateRange?.start; + _selectedEnd = widget.initialDateRange?.end; + _entryMode = widget.initialEntryMode; + _autoValidate = false; + } + + void _handleOk() { + if (_entryMode == DatePickerEntryMode.input) { + final InputDateRangePickerState picker = _inputPickerKey.currentState; + if (!picker.validate()) { + setState(() { + _autoValidate = true; + }); + return; + } + } + final DateTimeRange selectedRange = _hasSelectedDateRange + ? DateTimeRange(start: _selectedStart, end: _selectedEnd) + : null; + + Navigator.pop(context, selectedRange); + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleEntryModeToggle() { + setState(() { + switch (_entryMode) { + case DatePickerEntryMode.calendar: + _autoValidate = false; + _entryMode = DatePickerEntryMode.input; + break; + + case DatePickerEntryMode.input: + // If invalid range (start after end), then just use the start date + if (_selectedStart != null && _selectedEnd != null && _selectedStart.isAfter(_selectedEnd)) { + _selectedEnd = null; + } + _entryMode = DatePickerEntryMode.calendar; + break; + } + }); + } + + void _handleStartDateChanged(DateTime date) { + setState(() => _selectedStart = date); + } + + void _handleEndDateChanged(DateTime date) { + setState(() => _selectedEnd = date); + } + + bool get _hasSelectedDateRange => _selectedStart != null && _selectedEnd != null; + + @override + Widget build(BuildContext context) { + final MediaQueryData mediaQuery = MediaQuery.of(context); + final Orientation orientation = mediaQuery.orientation; + final double textScaleFactor = math.min(mediaQuery.textScaleFactor, 1.3); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + Widget contents; + Size size; + ShapeBorder shape; + double elevation; + EdgeInsets insetPadding; + switch (_entryMode) { + case DatePickerEntryMode.calendar: + contents = _CalendarRangePickerDialog( + key: _calendarPickerKey, + selectedStartDate: _selectedStart, + selectedEndDate: _selectedEnd, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + currentDate: widget.currentDate, + onStartDateChanged: _handleStartDateChanged, + onEndDateChanged: _handleEndDateChanged, + onConfirm: _hasSelectedDateRange ? _handleOk : null, + onCancel: _handleCancel, + onToggleEntryMode: _handleEntryModeToggle, + // TODO(darrenaustin): localize 'SAVE' + confirmText: widget.saveText ?? 'SAVE', + // TODO(darrenaustin): localize 'SELECTED RANGE' + helpText: widget.helpText ?? 'SELECTED RANGE', + ); + size = mediaQuery.size; + insetPadding = const EdgeInsets.all(0.0); + shape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.zero) + ); + elevation = 0; + break; + + case DatePickerEntryMode.input: + contents = _InputDateRangePickerDialog( + selectedStartDate: _selectedStart, + selectedEndDate: _selectedEnd, + currentDate: widget.currentDate, + picker: Container( + padding: const EdgeInsets.symmetric(horizontal: 24), + height: orientation == Orientation.portrait + ? _inputFormPortraitHeight + : _inputFormLandscapeHeight, + child: Column( + children: [ + const Spacer(), + InputDateRangePicker( + key: _inputPickerKey, + initialStartDate: _selectedStart, + initialEndDate: _selectedEnd, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onStartDateChanged: _handleStartDateChanged, + onEndDateChanged: _handleEndDateChanged, + autofocus: true, + autovalidate: _autoValidate, + helpText: widget.helpText, + errorInvalidRangeText: widget.errorInvalidRangeText, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldStartHintText: widget.fieldStartHintText, + fieldEndHintText: widget.fieldEndHintText, + fieldStartLabelText: widget.fieldStartLabelText, + fieldEndLabelText: widget.fieldEndLabelText, + ), + const Spacer(), + ], + ), + ), + onConfirm: _handleOk, + onCancel: _handleCancel, + onToggleEntryMode: _handleEntryModeToggle, + confirmText: widget.confirmText ?? localizations.okButtonLabel, + cancelText: widget.cancelText ?? localizations.cancelButtonLabel, + // TODO(darrenaustin): localize 'SELECTED DATE RANGE' + helpText: widget.helpText ?? 'SELECTED DATE RANGE', + ); + final DialogTheme dialogTheme = Theme.of(context).dialogTheme; + size = orientation == Orientation.portrait ? _inputPortraitDialogSize : _inputLandscapeDialogSize; + insetPadding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0); + // The default dialog shape is radius 2 rounded rect, but the spec has + // been updated to 4, so we will use that here for the Input Date Range + // Picker, but only if there isn't one provided in the theme. + shape = dialogTheme.shape ?? const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)) + ); + elevation = dialogTheme.elevation ?? 24; + break; + } + + return Dialog( + child: AnimatedContainer( + width: size.width, + height: size.height, + duration: _dialogSizeAnimationDuration, + curve: Curves.easeIn, + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: textScaleFactor, + ), + child: Builder(builder: (BuildContext context) { + return contents; + }), + ), + ), + insetPadding: insetPadding, + shape: shape, + elevation: elevation, + clipBehavior: Clip.antiAlias, + ); + } +} + +class _CalendarRangePickerDialog extends StatelessWidget { + const _CalendarRangePickerDialog({ + Key key, + @required this.selectedStartDate, + @required this.selectedEndDate, + @required this.firstDate, + @required this.lastDate, + @required this.currentDate, + @required this.onStartDateChanged, + @required this.onEndDateChanged, + @required this.onConfirm, + @required this.onCancel, + @required this.onToggleEntryMode, + @required this.confirmText, + @required this.helpText, + }) : super(key: key); + + final DateTime selectedStartDate; + final DateTime selectedEndDate; + final DateTime firstDate; + final DateTime lastDate; + final DateTime currentDate; + final ValueChanged onStartDateChanged; + final ValueChanged onEndDateChanged; + final VoidCallback onConfirm; + final VoidCallback onCancel; + final VoidCallback onToggleEntryMode; + final String confirmText; + final String helpText; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final Orientation orientation = MediaQuery.of(context).orientation; + final TextTheme textTheme = theme.textTheme; + final Color headerForeground = colorScheme.brightness == Brightness.light + ? colorScheme.onPrimary + : colorScheme.onSurface; + final Color headerDisabledForeground = headerForeground.withOpacity(0.38); + final String startDateText = utils.formatRangeStartDate(localizations, selectedStartDate, selectedEndDate); + final String endDateText = utils.formatRangeEndDate(localizations, selectedStartDate, selectedEndDate, DateTime.now()); + final TextStyle headlineStyle = textTheme.headline5; + final TextStyle startDateStyle = headlineStyle?.apply( + color: selectedStartDate != null ? headerForeground : headerDisabledForeground + ); + final TextStyle endDateStyle = headlineStyle?.apply( + color: selectedEndDate != null ? headerForeground : headerDisabledForeground + ); + final TextStyle saveButtonStyle = textTheme.button.apply( + color: onConfirm != null ? headerForeground : headerDisabledForeground + ); + + final IconButton entryModeIcon = IconButton( + padding: EdgeInsets.zero, + color: headerForeground, + icon: const Icon(Icons.edit), + tooltip: 'Switch to input', + onPressed: onToggleEntryMode, + ); + + return SafeArea( + top: false, + left: false, + right: false, + child: Scaffold( + appBar: AppBar( + leading: CloseButton( + onPressed: onCancel, + ), + actions: [ + if (orientation == Orientation.landscape) entryModeIcon, + ButtonTheme( + minWidth: 64, + child: FlatButton( + onPressed: onConfirm, + child: Text(confirmText, style: saveButtonStyle), + ), + ), + const SizedBox(width: 8), + ], + bottom: PreferredSize( + child: Row(children: [ + SizedBox(width: MediaQuery.of(context).size.width < 360 ? 42 : 72), + Expanded( + child: Semantics( + label: '$helpText $startDateText to $endDateText', + excludeSemantics: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + helpText, + style: textTheme.overline.apply( + color: headerForeground, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + startDateText, + style: startDateStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text(' – ', style: startDateStyle, + ), + Flexible( + child: Text( + endDateText, + style: endDateStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + if (orientation == Orientation.portrait) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: entryModeIcon, + ), + ]), + preferredSize: const Size(double.infinity, 64), + ), + ), + body: CalendarDateRangePicker( + initialStartDate: selectedStartDate, + initialEndDate: selectedEndDate, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + onStartDateChanged: onStartDateChanged, + onEndDateChanged: onEndDateChanged, + ), + ), + ); + } +} + +class _InputDateRangePickerDialog extends StatelessWidget { + const _InputDateRangePickerDialog({ + Key key, + @required this.selectedStartDate, + @required this.selectedEndDate, + @required this.currentDate, + @required this.picker, + @required this.onConfirm, + @required this.onCancel, + @required this.onToggleEntryMode, + @required this.confirmText, + @required this.cancelText, + @required this.helpText, + }) : super(key: key); + + final DateTime selectedStartDate; + final DateTime selectedEndDate; + final DateTime currentDate; + final Widget picker; + final VoidCallback onConfirm; + final VoidCallback onCancel; + final VoidCallback onToggleEntryMode; + final String confirmText; + final String cancelText; + final String helpText; + + String _formatDateRange(BuildContext context, DateTime start, DateTime end, DateTime now) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String startText = utils.formatRangeStartDate(localizations, start, end); + final String endText = utils.formatRangeEndDate(localizations, start, end, now); + if (start == null || end == null) { + // TODO(darrenaustin): localize 'Date Range' + return 'Date Range'; + } + if (Directionality.of(context) == TextDirection.ltr) { + return '$startText – $endText'; + } else { + return '$endText – $startText'; + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final Orientation orientation = MediaQuery.of(context).orientation; + final TextTheme textTheme = theme.textTheme; + + final Color dateColor = colorScheme.brightness == Brightness.light + ? colorScheme.onPrimary + : colorScheme.onSurface; + final TextStyle dateStyle = orientation == Orientation.landscape + ? textTheme.headline5?.apply(color: dateColor) + : textTheme.headline4?.apply(color: dateColor); + final String dateText = _formatDateRange(context, selectedStartDate, selectedEndDate, currentDate); + final String semanticDateText = selectedStartDate != null && selectedEndDate != null + ? '${localizations.formatMediumDate(selectedStartDate)} – ${localizations.formatMediumDate(selectedEndDate)}' + : ''; + + final Widget header = DatePickerHeader( + // TODO(darrenaustin): localize 'SELECT DATE RANGE' + helpText: helpText ?? 'SELECT DATE RANGE', + titleText: dateText, + titleSemanticsLabel: semanticDateText, + titleStyle: dateStyle, + orientation: orientation, + isShort: orientation == Orientation.landscape, + icon: Icons.calendar_today, + // TODO(darrenaustin): localize 'Switch to calendar' + iconTooltip: 'Switch to calendar', + onIconPressed: onToggleEntryMode, + ); + + final Widget actions = ButtonBar( + buttonTextTheme: ButtonTextTheme.primary, + layoutBehavior: ButtonBarLayoutBehavior.constrained, + children: [ + FlatButton( + child: Text(cancelText ?? localizations.cancelButtonLabel), + onPressed: onCancel, + ), + FlatButton( + child: Text(confirmText ?? localizations.okButtonLabel), + onPressed: onConfirm, + ), + ], + ); + + switch (orientation) { + case Orientation.portrait: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + Expanded(child: picker), + actions, + ], + ); + + case Orientation.landscape: + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: picker), + actions, + ], + ), + ), + ], + ); + } + return null; + } +} diff --git a/packages/flutter/lib/src/material/pickers/date_utils.dart b/packages/flutter/lib/src/material/pickers/date_utils.dart index c156b05cbb..3341b42055 100644 --- a/packages/flutter/lib/src/material/pickers/date_utils.dart +++ b/packages/flutter/lib/src/material/pickers/date_utils.dart @@ -12,11 +12,18 @@ import '../material_localizations.dart'; +import 'date_picker_common.dart'; + /// Returns a [DateTime] with just the date of the original, but no time set. DateTime dateOnly(DateTime date) { return DateTime(date.year, date.month, date.day); } +/// Returns a [DateTimeRange] with the dates of the original without any times set. +DateTimeRange datesOnly(DateTimeRange range) { + return DateTimeRange(start: dateOnly(range.start), end: dateOnly(range.end)); +} + /// Returns true if the two [DateTime] objects have the same day, month, and /// year. bool isSameDay(DateTime dateA, DateTime dateB) { @@ -120,3 +127,31 @@ int getDaysInMonth(int year, int month) { const List daysInMonth = [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; return daysInMonth[month - 1]; } + +/// Returns a locale-appropriate string to describe the start of a date range. +/// +/// If `startDate` is null, then it defaults to 'Start Date', otherwise if it +/// is in the same year as the `endDate` then it will use the short month +/// day format (i.e. 'Jan 21'). Otherwise it will return the short date format +/// (i.e. 'Jan 21, 2020'). +String formatRangeStartDate(MaterialLocalizations localizations, DateTime startDate, DateTime endDate) { + return startDate == null + ? 'Start Date' + : (endDate == null || startDate.year == endDate.year) + ? localizations.formatShortMonthDay(startDate) + : localizations.formatShortDate(startDate); +} + +/// Returns an locale-appropriate string to describe the end of a date range. +/// +/// If `endDate` is null, then it defaults to 'End Date', otherwise if it +/// is in the same year as the `startDate` and the `currentDate` then it will +/// just use the short month day format (i.e. 'Jan 21'), otherwise it will +/// include the year (i.e. 'Jan 21, 2020'). +String formatRangeEndDate(MaterialLocalizations localizations, DateTime startDate, DateTime endDate, DateTime currentDate) { + return endDate == null + ? 'End Date' + : (startDate != null && startDate.year == endDate.year && startDate.year == currentDate.year) + ? localizations.formatShortMonthDay(endDate) + : localizations.formatShortDate(endDate); +} diff --git a/packages/flutter/lib/src/material/pickers/input_date_picker.dart b/packages/flutter/lib/src/material/pickers/input_date_picker.dart index 6c0af3aa64..5e48fd47d8 100644 --- a/packages/flutter/lib/src/material/pickers/input_date_picker.dart +++ b/packages/flutter/lib/src/material/pickers/input_date_picker.dart @@ -8,15 +8,11 @@ import 'package:flutter/widgets.dart'; import '../input_border.dart'; import '../input_decorator.dart'; import '../material_localizations.dart'; -import '../text_field.dart'; import '../text_form_field.dart'; import 'date_picker_common.dart'; import 'date_utils.dart' as utils; -const double _inputPortraitHeight = 98.0; -const double _inputLandscapeHeight = 108.0; - /// A [TextFormField] configured to accept and validate a date entered by the user. /// /// The text entered into this field will be constrained to only allow digits @@ -227,54 +223,60 @@ class _InputDatePickerFormFieldState extends State { return OrientationBuilder(builder: (BuildContext context, Orientation orientation) { assert(orientation != null); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 24), - height: orientation == Orientation.portrait ? _inputPortraitHeight : _inputLandscapeHeight, - child: Column( - children: [ - const Spacer(), - TextFormField( - decoration: InputDecoration( - border: const UnderlineInputBorder(), - filled: true, - // TODO(darrenaustin): localize 'mm/dd/yyyy' and 'Enter Date' - hintText: widget.fieldHintText ?? 'mm/dd/yyyy', - labelText: widget.fieldLabelText ?? 'Enter Date', - ), - validator: _validateDate, - inputFormatters: [ - // TODO(darrenaustin): localize date separator '/' - _DateTextInputFormatter('/'), - ], - keyboardType: TextInputType.datetime, - onSaved: _handleSaved, - onFieldSubmitted: _handleSubmitted, - autofocus: widget.autofocus, - controller: _controller, - ), - const Spacer(), - ], + return TextFormField( + decoration: InputDecoration( + border: const UnderlineInputBorder(), + filled: true, + // TODO(darrenaustin): localize 'mm/dd/yyyy' and 'Enter Date' + hintText: widget.fieldHintText ?? 'mm/dd/yyyy', + labelText: widget.fieldLabelText ?? 'Enter Date', ), + validator: _validateDate, + inputFormatters: [ + // TODO(darrenaustin): localize date separator '/' + DateTextInputFormatter('/'), + ], + keyboardType: TextInputType.datetime, + onSaved: _handleSaved, + onFieldSubmitted: _handleSubmitted, + autofocus: widget.autofocus, + controller: _controller, ); }); } } -class _DateTextInputFormatter extends TextInputFormatter { +/// A `TextInputFormatter` set up to format dates. +/// +/// Note: this is not publicly exported (see pickers.dart), as it is +/// just meant for internal use by `InputDatePickerFormField` and +/// `InputDateRangePicker`. +class DateTextInputFormatter extends TextInputFormatter { - _DateTextInputFormatter(this.separator); + /// Creates a date formatter with the given separator. + DateTextInputFormatter( + this.separator + ) : _filterFormatter = WhitelistingTextInputFormatter(RegExp('[\\d$_commonSeparators\\$separator]+')); + /// List of common separators that are used in dates. This is used to make + /// sure that if given platform's [TextInputType.datetime] keyboard doesn't + /// provide the given locale's separator character, they can still enter the + /// separator using one of these characters (slash, period, comma, dash, or + /// space). + static const String _commonSeparators = r'\/\.,-\s'; + + /// The date separator for the current locale. final String separator; - final WhitelistingTextInputFormatter _filterFormatter = - // Only allow digits and separators (slash, dot, comma, hyphen, space). - WhitelistingTextInputFormatter(RegExp(r'[\d\/\.,-\s]+')); + // Formatter that will filter out all characters except digits and date + // separators. + final WhitelistingTextInputFormatter _filterFormatter; @override TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { final TextEditingValue filteredValue = _filterFormatter.formatEditUpdate(oldValue, newValue); return filteredValue.copyWith( - // Replace any separator character with the given separator + // Replace any non-digits with the given separator text: filteredValue.text.replaceAll(RegExp(r'[\D]'), separator), ); } diff --git a/packages/flutter/lib/src/material/pickers/input_date_range_picker.dart b/packages/flutter/lib/src/material/pickers/input_date_range_picker.dart new file mode 100644 index 0000000000..26f6019b47 --- /dev/null +++ b/packages/flutter/lib/src/material/pickers/input_date_range_picker.dart @@ -0,0 +1,276 @@ +// Copyright 2014 The Flutter 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 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../input_border.dart'; +import '../input_decorator.dart'; +import '../material_localizations.dart'; +import '../text_field.dart'; + +import 'date_utils.dart' as utils; +import 'input_date_picker.dart' show DateTextInputFormatter; + +/// Provides a pair of text fields that allow the user to enter the start and +/// end dates that represent a range of dates. +/// +/// Note: this is not publicly exported (see pickers.dart), as it is just an +/// internal component used by [showDateRangePicker]. +class InputDateRangePicker extends StatefulWidget { + /// Creates a row with two text fields configured to accept the start and end dates + /// of a date range. + InputDateRangePicker({ + Key key, + DateTime initialStartDate, + DateTime initialEndDate, + @required DateTime firstDate, + @required DateTime lastDate, + @required this.onStartDateChanged, + @required this.onEndDateChanged, + this.helpText, + this.errorFormatText, + this.errorInvalidText, + this.errorInvalidRangeText, + this.fieldStartHintText, + this.fieldEndHintText, + this.fieldStartLabelText, + this.fieldEndLabelText, + this.autofocus = false, + this.autovalidate = false, + }) : initialStartDate = initialStartDate == null ? null : utils.dateOnly(initialStartDate), + initialEndDate = initialEndDate == null ? null : utils.dateOnly(initialEndDate), + assert(firstDate != null), + firstDate = utils.dateOnly(firstDate), + assert(lastDate != null), + lastDate = utils.dateOnly(lastDate), + assert(firstDate != null), + assert(lastDate != null), + assert(autofocus != null), + assert(autovalidate != null), + super(key: key); + + /// The [DateTime] that represents the start of the initial date range selection. + final DateTime initialStartDate; + + /// The [DateTime] that represents the end of the initial date range selection. + final DateTime initialEndDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// Called when the user changes the start date of the selected range. + final ValueChanged onStartDateChanged; + + /// Called when the user changes the end date of the selected range. + final ValueChanged onEndDateChanged; + + /// The text that is displayed at the top of the header. + /// + /// This is used to indicate to the user what they are selecting a date for. + final String helpText; + + /// Error text used to indicate the text in a field is not a valid date. + final String errorFormatText; + + /// Error text used to indicate the date in a field is not in the valid range + /// of [firstDate] - [lastDate]. + final String errorInvalidText; + + /// Error text used to indicate the dates given don't form a valid date + /// range (i.e. the start date is after the end date). + final String errorInvalidRangeText; + + /// Hint text shown when the start date field is empty. + final String fieldStartHintText; + + /// Hint text shown when the end date field is empty. + final String fieldEndHintText; + + /// Label used for the start date field. + final String fieldStartLabelText; + + /// Label used for the end date field. + final String fieldEndLabelText; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// If true, this the date fields will validate and update their error text + /// immediately after every change. Otherwise, you must call + /// [InputDateRangePickerState.validate] to validate. + final bool autovalidate; + + @override + InputDateRangePickerState createState() => InputDateRangePickerState(); +} + +/// The current state of an [InputDateRangePicker]. Can be used to +/// [validate] the date field entries. +class InputDateRangePickerState extends State { + String _startInputText; + String _endInputText; + DateTime _startDate; + DateTime _endDate; + TextEditingController _startController; + TextEditingController _endController; + String _startErrorText; + String _endErrorText; + bool _autoSelected = false; + List _inputFormatters; + + @override + void initState() { + super.initState(); + _startDate = widget.initialStartDate; + _startController = TextEditingController(); + _endDate = widget.initialEndDate; + _endController = TextEditingController(); + } + + @override + void dispose() { + _startController.dispose(); + _endController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + _inputFormatters = [ + // TODO(darrenaustin): localize date separator '/' + DateTextInputFormatter('/'), + ]; + if (_startDate != null) { + _startInputText = localizations.formatCompactDate(_startDate); + final bool selectText = widget.autofocus && !_autoSelected; + _updateController(_startController, _startInputText, selectText); + _autoSelected = selectText; + } + + if (_endDate != null) { + _endInputText = localizations.formatCompactDate(_endDate); + _updateController(_endController, _endInputText, false); + } + } + + /// Validates that the text in the start and end fields represent a valid + /// date range. + /// + /// Will return true if the range is valid. If not, it will + /// return false and display an appropriate error message under one of the + /// text fields. + bool validate() { + String startError = _validateDate(_startDate); + final String endError = _validateDate(_endDate); + if (startError == null && endError == null) { + if (_startDate.isAfter(_endDate)) { + // TODO(darrenaustin): localize 'Invalid range.' + startError = widget.errorInvalidRangeText ?? 'Invalid range.'; + } + } + setState(() { + _startErrorText = startError; + _endErrorText = endError; + }); + return startError == null && endError == null; + } + + DateTime _parseDate(String text) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return localizations.parseCompactDate(text); + } + + String _validateDate(DateTime date) { + if (date == null) { + // TODO(darrenaustin): localize 'Invalid format.' + return widget.errorFormatText ?? 'Invalid format.'; + } else if (date.isBefore(widget.firstDate) || date.isAfter(widget.lastDate)) { + // TODO(darrenaustin): localize 'Out of range.' + return widget.errorInvalidText ?? 'Out of range.'; + } + return null; + } + + void _updateController(TextEditingController controller, String text, bool selectText) { + TextEditingValue textEditingValue = controller.value.copyWith(text: text); + if (selectText) { + textEditingValue = textEditingValue.copyWith(selection: TextSelection( + baseOffset: 0, + extentOffset: text.length, + )); + } + controller.value = textEditingValue; + } + + void _handleStartChanged(String text) { + setState(() { + _startInputText = text; + _startDate = _parseDate(text); + widget.onStartDateChanged?.call(_startDate); + }); + if (widget.autovalidate) { + validate(); + } + } + + void _handleEndChanged(String text) { + setState(() { + _endInputText = text; + _endDate = _parseDate(text); + widget.onEndDateChanged?.call(_endDate); + }); + if (widget.autovalidate) { + validate(); + } + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _startController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + filled: true, + // TODO(darrenaustin): localize 'mm/dd/yyyy' and 'Start Date' + hintText: widget.fieldStartHintText ?? 'mm/dd/yyyy', + labelText: widget.fieldStartLabelText ?? 'Start Date', + errorText: _startErrorText, + ), + inputFormatters: _inputFormatters, + keyboardType: TextInputType.datetime, + onChanged: _handleStartChanged, + autofocus: widget.autofocus, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _endController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + filled: true, + // TODO(darrenaustin): localize 'mm/dd/yyyy' and 'End Date' + hintText: widget.fieldEndHintText ?? 'mm/dd/yyyy', + labelText: widget.fieldEndLabelText ?? 'End Date', + errorText: _endErrorText, + ), + inputFormatters: _inputFormatters, + keyboardType: TextInputType.datetime, + onChanged: _handleEndChanged, + ), + ), + ], + ); + } +} diff --git a/packages/flutter/lib/src/material/pickers/pickers.dart b/packages/flutter/lib/src/material/pickers/pickers.dart index 8f834046da..c68272c8dd 100644 --- a/packages/flutter/lib/src/material/pickers/pickers.dart +++ b/packages/flutter/lib/src/material/pickers/pickers.dart @@ -4,7 +4,12 @@ // Date Picker public API export 'calendar_date_picker.dart' show CalendarDatePicker; -export 'date_picker_common.dart' show DatePickerEntryMode, DatePickerMode, SelectableDayPredicate; +export 'date_picker_common.dart' show + DatePickerEntryMode, + DatePickerMode, + DateTimeRange, + SelectableDayPredicate; export 'date_picker_deprecated.dart'; export 'date_picker_dialog.dart' show showDatePicker; +export 'date_range_picker_dialog.dart' show showDateRangePicker; export 'input_date_picker.dart' show InputDatePickerFormField; diff --git a/packages/flutter/test/material/date_range_picker_test.dart b/packages/flutter/test/material/date_range_picker_test.dart new file mode 100644 index 0000000000..71d8691575 --- /dev/null +++ b/packages/flutter/test/material/date_range_picker_test.dart @@ -0,0 +1,412 @@ +// Copyright 2014 The Flutter 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_tester.dart'; + +void main() { + DateTime firstDate; + DateTime lastDate; + DateTimeRange initialDateRange; + DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar; + + String cancelText; + String confirmText; + String errorInvalidRangeText; + String errorFormatText; + String errorInvalidText; + String fieldStartHintText; + String fieldEndHintText; + String fieldStartLabelText; + String fieldEndLabelText; + String helpText; + String saveText; + + setUp(() { + firstDate = DateTime(2015, DateTime.january, 1); + lastDate = DateTime(2016, DateTime.december, 31); + initialDateRange = DateTimeRange( + start: DateTime(2016, DateTime.january, 15), + end: DateTime(2016, DateTime.january, 25), + ); + initialEntryMode = DatePickerEntryMode.calendar; + + cancelText = null; + confirmText = null; + errorInvalidRangeText = null; + errorFormatText = null; + errorInvalidText = null; + fieldStartHintText = null; + fieldEndHintText = null; + fieldStartLabelText = null; + fieldEndLabelText = null; + helpText = null; + saveText = null; + }); + + Future preparePicker(WidgetTester tester, Future callback(Future date)) async { + BuildContext buttonContext; + await tester.pumpWidget(MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return RaisedButton( + onPressed: () { + buttonContext = context; + }, + child: const Text('Go'), + ); + }, + ), + ), + )); + + await tester.tap(find.text('Go')); + expect(buttonContext, isNotNull); + + final Future range = showDateRangePicker( + context: buttonContext, + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: initialEntryMode, + cancelText: cancelText, + confirmText: confirmText, + errorInvalidRangeText: errorInvalidRangeText, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + fieldStartHintText: fieldStartHintText, + fieldEndHintText: fieldEndHintText, + fieldStartLabelText: fieldStartLabelText, + fieldEndLabelText: fieldEndLabelText, + helpText: helpText, + saveText: saveText, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + await callback(range); + } + + testWidgets('Save and help text is used', (WidgetTester tester) async { + helpText = 'help'; + saveText = 'make it so'; + await preparePicker(tester, (Future range) async { + expect(find.text(helpText), findsOneWidget); + expect(find.text(saveText), findsOneWidget); + }); + }); + + testWidgets('Initial date is the default', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('SAVE')); + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.january, 15), + end: DateTime(2016, DateTime.january, 25) + )); + }); + }); + + testWidgets('Can cancel', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.byIcon(Icons.close)); + expect(await range, isNull); + }); + }); + + testWidgets('Can select a range', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('14').first); + await tester.tap(find.text('SAVE')); + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 14), + )); + }); + }); + + testWidgets('Tapping earlier date resets selected range', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('11').first); + await tester.tap(find.text('15').first); + await tester.tap(find.text('SAVE')); + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.january, 11), + end: DateTime(2016, DateTime.january, 15), + )); + }); + }); + + testWidgets('Can select single day range', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('12').first); + await tester.tap(find.text('SAVE')); + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 12), + )); + }); + }); + + testWidgets('Cannot select a day outside bounds', (WidgetTester tester) async { + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 13), + end: DateTime(2017, DateTime.january, 15), + ); + firstDate = DateTime(2017, DateTime.january, 12); + lastDate = DateTime(2017, DateTime.january, 16); + await preparePicker(tester, (Future range) async { + // Earlier than firstDate. Should be ignored. + await tester.tap(find.text('10')); + // Later than lastDate. Should be ignored. + await tester.tap(find.text('20')); + await tester.tap(find.text('SAVE')); + // We should still be on the initial date. + expect(await range, initialDateRange); + }); + }); + + testWidgets('Can toggle to input entry mode', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + expect(find.byType(TextField), findsNothing); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNWidgets(2)); + }); + }); + + testWidgets('Toggle to input mode keeps selected date', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('14').first); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 14), + )); + }); + }); + + group('Haptic feedback', () { + const Duration hapticFeedbackInterval = Duration(milliseconds: 10); + FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + ); + firstDate = DateTime(2017, DateTime.january, 10); + lastDate = DateTime(2018, DateTime.january, 20); + }); + + tearDown(() { + feedback?.dispose(); + }); + + testWidgets('Selecting dates vibrates', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('10').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 1); + await tester.tap(find.text('12').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 2); + await tester.tap(find.text('14').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 3); + }); + }); + + testWidgets('Tapping unselectable date does not vibrate', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('8').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 0); + }); + }); + }); + + group('Input mode', () { + setUp(() { + firstDate = DateTime(2015, DateTime.january, 1); + lastDate = DateTime(2017, DateTime.december, 31); + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + ); + initialEntryMode = DatePickerEntryMode.input; + }); + + testWidgets('Initial entry mode is used', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + expect(find.byType(TextField), findsNWidgets(2)); + }); + }); + + testWidgets('All custom strings are used', (WidgetTester tester) async { + initialDateRange = null; + cancelText = 'nope'; + confirmText = 'yep'; + fieldStartHintText = 'hint1'; + fieldEndHintText = 'hint2'; + fieldStartLabelText = 'label1'; + fieldEndLabelText = 'label2'; + helpText = 'help'; + await preparePicker(tester, (Future range) async { + expect(find.text(cancelText), findsOneWidget); + expect(find.text(confirmText), findsOneWidget); + expect(find.text(fieldStartHintText), findsOneWidget); + expect(find.text(fieldEndHintText), findsOneWidget); + expect(find.text(fieldStartLabelText), findsOneWidget); + expect(find.text(fieldEndLabelText), findsOneWidget); + expect(find.text(helpText), findsOneWidget); + }); + }); + + testWidgets('Initial date is the default', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + await tester.tap(find.text('OK')); + expect(await range, DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + )); + }); + }); + + testWidgets('Can toggle to calendar entry mode', (WidgetTester tester) async { + await preparePicker(tester, (Future range) async { + expect(find.byType(TextField), findsNWidgets(2)); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + }); + + testWidgets('Toggle to calendar mode keeps selected date', (WidgetTester tester) async { + initialDateRange = null; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/27/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + await tester.tap(find.text('SAVE')); + + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.december, 25), + end: DateTime(2016, DateTime.december, 27), + )); + }); + }); + + testWidgets('Entered text returns range', (WidgetTester tester) async { + initialDateRange = null; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/27/2016'); + await tester.tap(find.text('OK')); + + expect(await range, DateTimeRange( + start: DateTime(2016, DateTime.december, 25), + end: DateTime(2016, DateTime.december, 27), + )); + }); + }); + + testWidgets('Too short entered text shows error', (WidgetTester tester) async { + initialDateRange = null; + errorFormatText = 'oops'; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25'); + await tester.enterText(find.byType(TextField).at(1), '12/25'); + expect(find.text(errorFormatText), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText), findsNWidgets(2)); + }); + }); + + testWidgets('Bad format entered text shows error', (WidgetTester tester) async { + initialDateRange = null; + errorFormatText = 'oops'; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '20202014'); + await tester.enterText(find.byType(TextField).at(1), '20212014'); + expect(find.text(errorFormatText), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText), findsNWidgets(2)); + }); + }); + + testWidgets('Invalid entered text shows error', (WidgetTester tester) async { + initialDateRange = null; + errorInvalidText = 'oops'; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '08/08/2014'); + await tester.enterText(find.byType(TextField).at(1), '08/08/2014'); + expect(find.text(errorInvalidText), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidText), findsNWidgets(2)); + }); + }); + + testWidgets('End before start date shows error', (WidgetTester tester) async { + initialDateRange = null; + errorInvalidRangeText = 'oops'; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + expect(find.text(errorInvalidRangeText), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidRangeText), findsOneWidget); + }); + }); + + testWidgets('Error text only displayed for invalid date', (WidgetTester tester) async { + initialDateRange = null; + errorInvalidText = 'oops'; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/2016'); + await tester.enterText(find.byType(TextField).at(1), '01/01/2018'); + expect(find.text(errorInvalidText), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidText), findsOneWidget); + }); + }); + + testWidgets('End before start date does not get passed to calendar mode', (WidgetTester tester) async { + initialDateRange = null; + await preparePicker(tester, (Future range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + await tester.tap(find.text('SAVE')); + await tester.pumpAndSettle(); + + // Save button should be disabled, so dialog should still be up + // with the first date selected, but no end date + expect(find.text('Dec 27'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }); + }); + }); +}