Unverified Commit 4560ebcf authored by Darren Austin's avatar Darren Austin Committed by GitHub

Implementation of the Material Date Range Picker. (#55939)

parent cd67da26
......@@ -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);
}
......@@ -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<DateTime> 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: <Widget>[
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;
......
......@@ -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,
......
......@@ -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<int> daysInMonth = <int>[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);
}
......@@ -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<InputDatePickerFormField> {
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: <Widget>[
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: <TextInputFormatter>[
// 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: <TextInputFormatter>[
// TODO(darrenaustin): localize date separator '/'
DateTextInputFormatter('/'),
],
keyboardType: TextInputType.datetime,
onSaved: _handleSaved,
onFieldSubmitted: _handleSubmitted,
autofocus: widget.autofocus,
controller: _controller,
);
});
}
}
class _DateTextInputFormatter extends TextInputFormatter {
_DateTextInputFormatter(this.separator);
/// 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 {
/// 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),
);
}
......
// 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<DateTime> onStartDateChanged;
/// Called when the user changes the end date of the selected range.
final ValueChanged<DateTime> 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<InputDateRangePicker> {
String _startInputText;
String _endInputText;
DateTime _startDate;
DateTime _endDate;
TextEditingController _startController;
TextEditingController _endController;
String _startErrorText;
String _endErrorText;
bool _autoSelected = false;
List<TextInputFormatter> _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 = <TextInputFormatter>[
// 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: <Widget>[
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,
),
),
],
);
}
}
......@@ -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;
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment