Created
December 29, 2020 09:50
-
-
Save RobertApikyan/ee76abf54cd449cbd190ad703bbaf8bc to your computer and use it in GitHub Desktop.
Date Range picker without mode icon
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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. | |
// @dart = 2.8 | |
import 'dart:math' as math; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter/widgets.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 : datesOnly(initialDateRange); | |
assert(firstDate != null); | |
firstDate = dateOnly(firstDate); | |
assert(lastDate != null); | |
lastDate = 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 = 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, | |
confirmText: widget.saveText ?? localizations.saveButtonLabel, | |
helpText: widget.helpText ?? localizations.dateRangePickerHelpText, | |
); | |
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, | |
helpText: widget.helpText ?? localizations.dateRangePickerHelpText, | |
); | |
final DialogTheme dialogTheme = Theme.of(context).dialogTheme; | |
size = orientation == Orientation.portrait | |
? _inputPortraitDialogSize | |
: _inputLandscapeDialogSize; | |
insetPadding = | |
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0); | |
shape = dialogTheme.shape; | |
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 = | |
formatRangeStartDate(localizations, selectedStartDate, selectedEndDate); | |
final String endDateText = 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: localizations.inputDateModeButtonLabel, | |
// 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: TextButton( | |
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 = formatRangeStartDate(localizations, start, end); | |
final String endText = formatRangeEndDate(localizations, start, end, now); | |
if (start == null || end == null) { | |
return localizations.unspecifiedDateRange; | |
} | |
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( | |
helpText: helpText ?? localizations.dateRangePickerHelpText, | |
titleText: dateText, | |
titleSemanticsLabel: semanticDateText, | |
titleStyle: dateStyle, | |
orientation: orientation, | |
isShort: orientation == Orientation.landscape, | |
icon: Icons.calendar_today, | |
iconTooltip: localizations.calendarModeButtonLabel, | |
onIconPressed: onToggleEntryMode, | |
); | |
final Widget actions = Container( | |
alignment: AlignmentDirectional.centerEnd, | |
constraints: const BoxConstraints(minHeight: 52.0), | |
padding: const EdgeInsets.symmetric(horizontal: 8), | |
child: OverflowBar( | |
spacing: 8, | |
children: <Widget>[ | |
TextButton( | |
child: Text(cancelText ?? localizations.cancelButtonLabel), | |
onPressed: onCancel, | |
), | |
TextButton( | |
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; | |
} | |
} | |
// 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. | |
// @dart = 2. | |
/// Provides a pair of text fields that allow the user to enter the start and | |
/// end dates that represent a range of dates. | |
// | |
// 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 : dateOnly(initialStartDate), | |
initialEndDate = | |
initialEndDate == null ? null : dateOnly(initialEndDate), | |
assert(firstDate != null), | |
firstDate = dateOnly(firstDate), | |
assert(lastDate != null), | |
lastDate = 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; | |
@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); | |
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)) { | |
startError = widget.errorInvalidRangeText ?? | |
MaterialLocalizations.of(context).invalidDateRangeLabel; | |
} | |
} | |
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) { | |
return widget.errorFormatText ?? | |
MaterialLocalizations.of(context).invalidDateFormatLabel; | |
} else if (date.isBefore(widget.firstDate) || | |
date.isAfter(widget.lastDate)) { | |
return widget.errorInvalidText ?? | |
MaterialLocalizations.of(context).dateOutOfRangeLabel; | |
} | |
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) { | |
final MaterialLocalizations localizations = | |
MaterialLocalizations.of(context); | |
final InputDecorationTheme inputTheme = | |
Theme.of(context).inputDecorationTheme; | |
return Row( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
Expanded( | |
child: TextField( | |
controller: _startController, | |
decoration: InputDecoration( | |
border: inputTheme.border ?? const UnderlineInputBorder(), | |
filled: inputTheme.filled ?? true, | |
hintText: widget.fieldStartHintText ?? localizations.dateHelpText, | |
labelText: widget.fieldStartLabelText ?? | |
localizations.dateRangeStartLabel, | |
errorText: _startErrorText, | |
), | |
keyboardType: TextInputType.datetime, | |
onChanged: _handleStartChanged, | |
autofocus: widget.autofocus, | |
), | |
), | |
const SizedBox(width: 8), | |
Expanded( | |
child: TextField( | |
controller: _endController, | |
decoration: InputDecoration( | |
border: inputTheme.border ?? const UnderlineInputBorder(), | |
filled: inputTheme.filled ?? true, | |
hintText: widget.fieldEndHintText ?? localizations.dateHelpText, | |
labelText: | |
widget.fieldEndLabelText ?? localizations.dateRangeEndLabel, | |
errorText: _endErrorText, | |
), | |
keyboardType: TextInputType.datetime, | |
onChanged: _handleEndChanged, | |
), | |
), | |
], | |
); | |
} | |
} | |
// 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. | |
// @dart = 2.8 | |
const Duration _monthScrollDuration = Duration(milliseconds: 200); | |
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. | |
// | |
// 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 ? dateOnly(initialStartDate) : null, | |
initialEndDate = | |
initialEndDate != null ? dateOnly(initialEndDate) : null, | |
assert(firstDate != null), | |
assert(lastDate != null), | |
firstDate = dateOnly(firstDate), | |
lastDate = dateOnly(lastDate), | |
currentDate = 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> { | |
final GlobalKey _scrollViewKey = GlobalKey(); | |
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 = 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 => 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 = 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( | |
child: _CalendarKeyboardNavigator( | |
firstDate: widget.firstDate, | |
lastDate: widget.lastDate, | |
initialFocusedDay: | |
_startDate ?? widget.initialStartDate ?? widget.currentDate, | |
// 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( | |
key: _scrollViewKey, | |
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 _CalendarKeyboardNavigator extends StatefulWidget { | |
const _CalendarKeyboardNavigator({ | |
Key key, | |
@required this.child, | |
@required this.firstDate, | |
@required this.lastDate, | |
@required this.initialFocusedDay, | |
}) : super(key: key); | |
final Widget child; | |
final DateTime firstDate; | |
final DateTime lastDate; | |
final DateTime initialFocusedDay; | |
@override | |
_CalendarKeyboardNavigatorState createState() => | |
_CalendarKeyboardNavigatorState(); | |
} | |
class _CalendarKeyboardNavigatorState | |
extends State<_CalendarKeyboardNavigator> { | |
Map<LogicalKeySet, Intent> _shortcutMap; | |
Map<Type, Action<Intent>> _actionMap; | |
FocusNode _dayGridFocus; | |
TraversalDirection _dayTraversalDirection; | |
DateTime _focusedDay; | |
@override | |
void initState() { | |
super.initState(); | |
_shortcutMap = <LogicalKeySet, Intent>{ | |
LogicalKeySet(LogicalKeyboardKey.arrowLeft): | |
const DirectionalFocusIntent(TraversalDirection.left), | |
LogicalKeySet(LogicalKeyboardKey.arrowRight): | |
const DirectionalFocusIntent(TraversalDirection.right), | |
LogicalKeySet(LogicalKeyboardKey.arrowDown): | |
const DirectionalFocusIntent(TraversalDirection.down), | |
LogicalKeySet(LogicalKeyboardKey.arrowUp): | |
const DirectionalFocusIntent(TraversalDirection.up), | |
}; | |
_actionMap = <Type, Action<Intent>>{ | |
NextFocusIntent: | |
CallbackAction<NextFocusIntent>(onInvoke: _handleGridNextFocus), | |
PreviousFocusIntent: CallbackAction<PreviousFocusIntent>( | |
onInvoke: _handleGridPreviousFocus), | |
DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>( | |
onInvoke: _handleDirectionFocus), | |
}; | |
_dayGridFocus = FocusNode(debugLabel: 'Day Grid'); | |
} | |
@override | |
void dispose() { | |
_dayGridFocus.dispose(); | |
super.dispose(); | |
} | |
void _handleGridFocusChange(bool focused) { | |
setState(() { | |
if (focused) { | |
_focusedDay ??= widget.initialFocusedDay; | |
} | |
}); | |
} | |
/// Move focus to the next element after the day grid. | |
void _handleGridNextFocus(NextFocusIntent intent) { | |
_dayGridFocus.requestFocus(); | |
_dayGridFocus.nextFocus(); | |
} | |
/// Move focus to the previous element before the day grid. | |
void _handleGridPreviousFocus(PreviousFocusIntent intent) { | |
_dayGridFocus.requestFocus(); | |
_dayGridFocus.previousFocus(); | |
} | |
/// Move the internal focus date in the direction of the given intent. | |
/// | |
/// This will attempt to move the focused day to the next selectable day in | |
/// the given direction. If the new date is not in the current month, then | |
/// the page view will be scrolled to show the new date's month. | |
/// | |
/// For horizontal directions, it will move forward or backward a day (depending | |
/// on the current [TextDirection]). For vertical directions it will move up and | |
/// down a week at a time. | |
void _handleDirectionFocus(DirectionalFocusIntent intent) { | |
assert(_focusedDay != null); | |
setState(() { | |
final DateTime nextDate = | |
_nextDateInDirection(_focusedDay, intent.direction); | |
if (nextDate != null) { | |
_focusedDay = nextDate; | |
_dayTraversalDirection = intent.direction; | |
} | |
}); | |
} | |
static const Map<TraversalDirection, int> _directionOffset = | |
<TraversalDirection, int>{ | |
TraversalDirection.up: -DateTime.daysPerWeek, | |
TraversalDirection.right: 1, | |
TraversalDirection.down: DateTime.daysPerWeek, | |
TraversalDirection.left: -1, | |
}; | |
int _dayDirectionOffset( | |
TraversalDirection traversalDirection, TextDirection textDirection) { | |
// Swap left and right if the text direction if RTL | |
if (textDirection == TextDirection.rtl) { | |
if (traversalDirection == TraversalDirection.left) | |
traversalDirection = TraversalDirection.right; | |
else if (traversalDirection == TraversalDirection.right) | |
traversalDirection = TraversalDirection.left; | |
} | |
return _directionOffset[traversalDirection]; | |
} | |
DateTime _nextDateInDirection(DateTime date, TraversalDirection direction) { | |
final TextDirection textDirection = Directionality.of(context); | |
final DateTime nextDate = | |
addDaysToDate(date, _dayDirectionOffset(direction, textDirection)); | |
if (!nextDate.isBefore(widget.firstDate) && | |
!nextDate.isAfter(widget.lastDate)) { | |
return nextDate; | |
} | |
return null; | |
} | |
@override | |
Widget build(BuildContext context) { | |
return FocusableActionDetector( | |
shortcuts: _shortcutMap, | |
actions: _actionMap, | |
focusNode: _dayGridFocus, | |
onFocusChange: _handleGridFocusChange, | |
child: _FocusedDate( | |
date: _dayGridFocus.hasFocus ? _focusedDay : null, | |
scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null, | |
child: widget.child, | |
), | |
); | |
} | |
} | |
/// InheritedWidget indicating what the current focused date is for its children. | |
/// | |
/// This is used by the [_MonthPicker] to let its children [_DayPicker]s know | |
/// what the currently focused date (if any) should be. | |
class _FocusedDate extends InheritedWidget { | |
const _FocusedDate({ | |
Key key, | |
Widget child, | |
this.date, | |
this.scrollDirection, | |
}) : super(key: key, child: child); | |
final DateTime date; | |
final TraversalDirection scrollDirection; | |
@override | |
bool updateShouldNotify(_FocusedDate oldWidget) { | |
return !isSameDay(date, oldWidget.date) || | |
scrollDirection != oldWidget.scrollDirection; | |
} | |
static _FocusedDate of(BuildContext context) { | |
return context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); | |
} | |
} | |
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 StatefulWidget { | |
/// 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; | |
@override | |
_MonthItemState createState() => _MonthItemState(); | |
} | |
class _MonthItemState extends State<_MonthItem> { | |
/// List of [FocusNode]s, one for each day of the month. | |
List<FocusNode> _dayFocusNodes; | |
@override | |
void initState() { | |
super.initState(); | |
final int daysInMonth = | |
getDaysInMonth(widget.displayedMonth.year, widget.displayedMonth.month); | |
_dayFocusNodes = List<FocusNode>.generate( | |
daysInMonth, | |
(int index) => | |
FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}')); | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
// Check to see if the focused date is in this month, if so focus it. | |
final DateTime focusedDate = _FocusedDate.of(context)?.date; | |
if (focusedDate != null && | |
isSameMonth(widget.displayedMonth, focusedDate)) { | |
_dayFocusNodes[focusedDate.day - 1].requestFocus(); | |
} | |
} | |
@override | |
void dispose() { | |
for (final FocusNode node in _dayFocusNodes) { | |
node.dispose(); | |
} | |
super.dispose(); | |
} | |
Color _highlightColor(BuildContext context) { | |
return Theme.of(context).colorScheme.primary.withOpacity(0.12); | |
} | |
void _dayFocusChanged(bool focused) { | |
if (focused) { | |
final TraversalDirection focusDirection = | |
_FocusedDate.of(context)?.scrollDirection; | |
if (focusDirection != null) { | |
ScrollPositionAlignmentPolicy policy = | |
ScrollPositionAlignmentPolicy.explicit; | |
switch (focusDirection) { | |
case TraversalDirection.up: | |
case TraversalDirection.left: | |
policy = ScrollPositionAlignmentPolicy.keepVisibleAtStart; | |
break; | |
case TraversalDirection.right: | |
case TraversalDirection.down: | |
policy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; | |
break; | |
} | |
Scrollable.ensureVisible( | |
primaryFocus.context, | |
duration: _monthScrollDuration, | |
alignmentPolicy: policy, | |
); | |
} | |
} | |
} | |
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(widget.lastDate) || | |
dayToBuild.isBefore(widget.firstDate); | |
BoxDecoration decoration; | |
TextStyle itemStyle = textTheme.bodyText2; | |
final bool isRangeSelected = | |
widget.selectedDateStart != null && widget.selectedDateEnd != null; | |
final bool isSelectedDayStart = widget.selectedDateStart != null && | |
dayToBuild.isAtSameMomentAs(widget.selectedDateStart); | |
final bool isSelectedDayEnd = widget.selectedDateEnd != null && | |
dayToBuild.isAtSameMomentAs(widget.selectedDateEnd); | |
final bool isInRange = isRangeSelected && | |
dayToBuild.isAfter(widget.selectedDateStart) && | |
dayToBuild.isBefore(widget.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 && | |
widget.selectedDateStart != widget.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 (isSameDay(widget.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) { | |
semanticLabel = | |
localizations.dateRangeStartDateSemanticLabel(semanticLabel); | |
} else if (isSelectedDayEnd) { | |
semanticLabel = | |
localizations.dateRangeEndDateSemanticLabel(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 = InkResponse( | |
focusNode: _dayFocusNodes[day - 1], | |
onTap: () => widget.onChanged(dayToBuild), | |
radius: _monthItemRowHeight / 2 + 4, | |
splashColor: colorScheme.primary.withOpacity(0.38), | |
onFocusChange: _dayFocusChanged, | |
child: dayWidget, | |
); | |
} | |
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 = widget.displayedMonth.year; | |
final int month = widget.displayedMonth.month; | |
final int daysInMonth = getDaysInMonth(year, month); | |
final int dayOffset = 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) && | |
widget.selectedDateStart != null && | |
widget.selectedDateEnd != null && | |
dateAfterLeadingPadding.isAfter(widget.selectedDateStart) && | |
!dateAfterLeadingPadding.isAfter(widget.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 = widget.selectedDateStart != null && | |
widget.selectedDateEnd != null && | |
!dateBeforeTrailingPadding.isBefore(widget.selectedDateStart) && | |
dateBeforeTrailingPadding.isBefore(widget.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(widget.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; | |
final Rect rectLeft = Rect.fromLTWH(0, 0, size.width / 2, size.height); | |
final Rect rectRight = | |
Rect.fromLTWH(size.width / 2, 0, size.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, size.width, size.height), | |
paint, | |
); | |
break; | |
default: | |
break; | |
} | |
} | |
@override | |
bool shouldRepaint(CustomPainter oldDelegate) => false; | |
} | |
// This is an internal implementation file. Even though there are public | |
// classes and functions defined here, they are only meant to be used by the | |
// date picker implementation and are not exported as part of the Material library. | |
// See pickers.dart for exactly what is considered part of the public API. | |
const double _datePickerHeaderLandscapeWidth = 152.0; | |
const double _datePickerHeaderPortraitHeight = 120.0; | |
const double _headerPaddingLandscape = 16.0; | |
/// Re-usable widget that displays the selected date (in large font) and the | |
/// help text above it. | |
/// | |
/// These types include: | |
/// | |
/// * 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. | |
class DatePickerHeader extends StatelessWidget { | |
/// Creates a header for use in a date picker dialog. | |
const DatePickerHeader({ | |
Key key, | |
@required this.helpText, | |
@required this.titleText, | |
this.titleSemanticsLabel, | |
@required this.titleStyle, | |
@required this.orientation, | |
this.isShort = false, | |
@required this.icon, | |
@required this.iconTooltip, | |
@required this.onIconPressed, | |
}) : assert(helpText != null), | |
assert(orientation != null), | |
assert(isShort != null), | |
super(key: key); | |
/// 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; | |
/// The text that is displayed at the center of the header. | |
final String titleText; | |
/// The semantic label associated with the [titleText]. | |
final String titleSemanticsLabel; | |
/// The [TextStyle] that the title text is displayed with. | |
final TextStyle titleStyle; | |
/// The orientation is used to decide how to layout its children. | |
final Orientation orientation; | |
/// Indicates the header is being displayed in a shorter/narrower context. | |
/// | |
/// This will be used to tighten up the space between the help text and date | |
/// text if `true`. Additionally, it will use a smaller typography style if | |
/// `true`. | |
/// | |
/// This is necessary for displaying the manual input mode in | |
/// landscape orientation, in order to account for the keyboard height. | |
final bool isShort; | |
/// The mode-switching icon that will be displayed in the lower right | |
/// in portrait, and lower left in landscape. | |
/// | |
/// The available icons are described in [Icons]. | |
final IconData icon; | |
/// The text that is displayed for the tooltip of the icon. | |
final String iconTooltip; | |
/// Callback when the user taps the icon in the header. | |
/// | |
/// The picker will use this to toggle between entry modes. | |
final VoidCallback onIconPressed; | |
@override | |
Widget build(BuildContext context) { | |
final ThemeData theme = Theme.of(context); | |
final ColorScheme colorScheme = theme.colorScheme; | |
final TextTheme textTheme = theme.textTheme; | |
// The header should use the primary color in light themes and surface color in dark | |
final bool isDark = colorScheme.brightness == Brightness.dark; | |
final Color primarySurfaceColor = | |
isDark ? colorScheme.surface : colorScheme.primary; | |
final Color onPrimarySurfaceColor = | |
isDark ? colorScheme.onSurface : colorScheme.onPrimary; | |
final TextStyle helpStyle = textTheme.overline?.copyWith( | |
color: onPrimarySurfaceColor, | |
); | |
final Text help = Text( | |
helpText, | |
style: helpStyle, | |
maxLines: 1, | |
overflow: TextOverflow.ellipsis, | |
); | |
final Text title = Text( | |
titleText, | |
semanticsLabel: titleSemanticsLabel ?? titleText, | |
style: titleStyle, | |
maxLines: orientation == Orientation.portrait ? 1 : 2, | |
overflow: TextOverflow.ellipsis, | |
); | |
final IconButton icon = IconButton( | |
icon: Icon(this.icon), | |
color: onPrimarySurfaceColor, | |
tooltip: iconTooltip, | |
onPressed: onIconPressed, | |
); | |
switch (orientation) { | |
case Orientation.portrait: | |
return SizedBox( | |
height: _datePickerHeaderPortraitHeight, | |
child: Material( | |
color: primarySurfaceColor, | |
child: Padding( | |
padding: const EdgeInsetsDirectional.only( | |
start: 24, | |
end: 12, | |
), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
const SizedBox(height: 16), | |
Flexible(child: help), | |
const SizedBox(height: 38), | |
Row( | |
children: <Widget>[ | |
Expanded(child: title), | |
icon, | |
], | |
), | |
], | |
), | |
), | |
), | |
); | |
case Orientation.landscape: | |
return SizedBox( | |
width: _datePickerHeaderLandscapeWidth, | |
child: Material( | |
color: primarySurfaceColor, | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
const SizedBox(height: 16), | |
Padding( | |
padding: const EdgeInsets.symmetric( | |
horizontal: _headerPaddingLandscape, | |
), | |
child: help, | |
), | |
SizedBox(height: isShort ? 16 : 56), | |
Expanded( | |
child: Padding( | |
padding: const EdgeInsets.symmetric( | |
horizontal: _headerPaddingLandscape, | |
), | |
child: title, | |
), | |
), | |
Padding( | |
padding: const EdgeInsets.symmetric( | |
horizontal: 4, | |
), | |
child: icon, | |
), | |
], | |
), | |
), | |
); | |
} | |
return null; | |
} | |
} | |
// 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. | |
// @dart = 2.8 | |
// Common date utility functions used by the date picker implementation | |
// This is an internal implementation file. Even though there are public | |
// classes and functions defined here, they are only meant to be used by the | |
// date picker implementation and are not exported as part of the Material library. | |
// See pickers.dart for exactly what is considered part of the public API. | |
/// 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, or are both null. | |
bool isSameDay(DateTime dateA, DateTime dateB) { | |
return dateA?.year == dateB?.year && | |
dateA?.month == dateB?.month && | |
dateA?.day == dateB?.day; | |
} | |
/// Returns true if the two [DateTime] objects have the same month, and | |
/// year, or are both null. | |
bool isSameMonth(DateTime dateA, DateTime dateB) { | |
return dateA?.year == dateB?.year && dateA?.month == dateB?.month; | |
} | |
/// Determines the number of months between two [DateTime] objects. | |
/// | |
/// For example: | |
/// ``` | |
/// DateTime date1 = DateTime(year: 2019, month: 6, day: 15); | |
/// DateTime date2 = DateTime(year: 2020, month: 1, day: 15); | |
/// int delta = monthDelta(date1, date2); | |
/// ``` | |
/// | |
/// The value for `delta` would be `7`. | |
int monthDelta(DateTime startDate, DateTime endDate) { | |
return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month; | |
} | |
/// Returns a [DateTime] with the added number of months and truncates any day | |
/// and time information. | |
/// | |
/// For example: | |
/// ``` | |
/// DateTime date = DateTime(year: 2019, month: 1, day: 15); | |
/// DateTime futureDate = _addMonthsToMonthDate(date, 3); | |
/// ``` | |
/// | |
/// `date` would be January 15, 2019. | |
/// `futureDate` would be April 1, 2019 since it adds 3 months and truncates | |
/// any additional date information. | |
DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { | |
return DateTime(monthDate.year, monthDate.month + monthsToAdd); | |
} | |
/// Returns a [DateTime] with the added number of days and no time set. | |
DateTime addDaysToDate(DateTime date, int days) { | |
return DateTime(date.year, date.month, date.day + days); | |
} | |
/// Computes the offset from the first day of the week that the first day of | |
/// the [month] falls on. | |
/// | |
/// For example, September 1, 2017 falls on a Friday, which in the calendar | |
/// localized for United States English appears as: | |
/// | |
/// ``` | |
/// S M T W T F S | |
/// _ _ _ _ _ 1 2 | |
/// ``` | |
/// | |
/// The offset for the first day of the months is the number of leading blanks | |
/// in the calendar, i.e. 5. | |
/// | |
/// The same date localized for the Russian calendar has a different offset, | |
/// because the first day of week is Monday rather than Sunday: | |
/// | |
/// ``` | |
/// M T W T F S S | |
/// _ _ _ _ 1 2 3 | |
/// ``` | |
/// | |
/// So the offset is 4, rather than 5. | |
/// | |
/// This code consolidates the following: | |
/// | |
/// - [DateTime.weekday] provides a 1-based index into days of week, with 1 | |
/// falling on Monday. | |
/// - [MaterialLocalizations.firstDayOfWeekIndex] provides a 0-based index | |
/// into the [MaterialLocalizations.narrowWeekdays] list. | |
/// - [MaterialLocalizations.narrowWeekdays] list provides localized names of | |
/// days of week, always starting with Sunday and ending with Saturday. | |
int firstDayOffset(int year, int month, MaterialLocalizations localizations) { | |
// 0-based day of week for the month and year, with 0 representing Monday. | |
final int weekdayFromMonday = DateTime(year, month).weekday - 1; | |
// 0-based start of week depending on the locale, with 0 representing Sunday. | |
int firstDayOfWeekIndex = localizations.firstDayOfWeekIndex; | |
// firstDayOfWeekIndex recomputed to be Monday-based, in order to compare with | |
// weekdayFromMonday. | |
firstDayOfWeekIndex = (firstDayOfWeekIndex - 1) % 7; | |
// Number of days between the first day of week appearing on the calendar, | |
// and the day corresponding to the first of the month. | |
return (weekdayFromMonday - firstDayOfWeekIndex) % 7; | |
} | |
/// Returns the number of days in a month, according to the proleptic | |
/// Gregorian calendar. | |
/// | |
/// This applies the leap year logic introduced by the Gregorian reforms of | |
/// 1582. It will not give valid results for dates prior to that time. | |
int getDaysInMonth(int year, int month) { | |
if (month == DateTime.february) { | |
final bool isLeapYear = | |
(year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0); | |
if (isLeapYear) return 29; | |
return 28; | |
} | |
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 | |
? localizations.dateRangeStartLabel | |
: (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 | |
? localizations.dateRangeEndLabel | |
: (startDate != null && | |
startDate.year == endDate.year && | |
startDate.year == currentDate.year) | |
? localizations.formatShortMonthDay(endDate) | |
: localizations.formatShortDate(endDate); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment