Implementation of the Material Date Range Picker. (#55939)
This commit is contained in:
@@ -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<DateTime> onStartDateChanged;
|
||||
|
||||
/// Called when the user changes the end date of the selected range.
|
||||
final ValueChanged<DateTime> onEndDateChanged;
|
||||
|
||||
@override
|
||||
_CalendarDateRangePickerState createState() => _CalendarDateRangePickerState();
|
||||
}
|
||||
|
||||
class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> {
|
||||
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: <Widget>[
|
||||
_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: <Widget>[
|
||||
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<Widget> _getDayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) {
|
||||
final List<Widget> result = <Widget>[];
|
||||
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<Widget> 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<DateTime> onChanged;
|
||||
|
||||
/// The earliest date the user is permitted to pick.
|
||||
final DateTime firstDate;
|
||||
|
||||
/// The latest date the user is permitted to pick.
|
||||
final DateTime lastDate;
|
||||
|
||||
/// 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<Widget> dayItems = <Widget>[];
|
||||
|
||||
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<Widget> paddedDayItems = <Widget>[];
|
||||
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<Widget> 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: <Widget>[
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DateTimeRange> 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<DateTimeRange>(
|
||||
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<InputDateRangePickerState> _inputPickerKey = GlobalKey<InputDateRangePickerState>();
|
||||
|
||||
@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: <Widget>[
|
||||
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<DateTime> onStartDateChanged;
|
||||
final ValueChanged<DateTime> 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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
Text(
|
||||
helpText,
|
||||
style: textTheme.overline.apply(
|
||||
color: headerForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
header,
|
||||
Expanded(child: picker),
|
||||
actions,
|
||||
],
|
||||
);
|
||||
|
||||
case Orientation.landscape:
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
header,
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Expanded(child: picker),
|
||||
actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
/// 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
412
packages/flutter/test/material/date_range_picker_test.dart
Normal file
412
packages/flutter/test/material/date_range_picker_test.dart
Normal file
@@ -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<void> preparePicker(WidgetTester tester, Future<void> callback(Future<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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<DateTimeRange> 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user