Last active
September 13, 2020 15:43
-
-
Save Amir-P/520f38fb90e2cbf621f550438f1566d8 to your computer and use it in GitHub Desktop.
Flutter date range picker using private classes from Flutter's SDK
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
// Original source code is by The Flutter Authors and I changed it slightly to meet my needs. | |
import 'dart:math' as math; | |
import 'package:flutter/gestures.dart' show DragStartBehavior; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter/widgets.dart'; | |
import 'date_utils.dart' as utils; | |
const Duration _monthScrollDuration = Duration(milliseconds: 200); | |
const double _monthItemHeaderHeight = 58.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 paginated calendar grid that allows a user to select a range | |
/// of dates. | |
class CalendarDateRangePicker extends StatefulWidget { | |
/// Creates a paginated 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; | |
PageController _controller; | |
int _currentIndex = 0; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = PageController(initialPage: _numberOfMonths - 1); | |
_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); | |
} | |
} | |
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) { | |
final MaterialLocalizations localizations = | |
MaterialLocalizations.of(context); | |
final ThemeData themeData = Theme.of(context); | |
final TextTheme textTheme = themeData.textTheme; | |
return Column( | |
children: <Widget>[ | |
Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 26), | |
child: _DayHeaders(), | |
), | |
Container( | |
height: _monthItemHeaderHeight, | |
padding: const EdgeInsets.symmetric(horizontal: 26), | |
alignment: AlignmentDirectional.centerStart, | |
child: Row( | |
children: [ | |
InkWell( | |
child: Icon( | |
Icons.arrow_back_ios, | |
color: _currentIndex == 0 ? Colors.black12 : null, | |
size: 15, | |
), | |
onTap: () => _controller.previousPage( | |
duration: Duration(milliseconds: 500), | |
curve: Curves.fastLinearToSlowEaseIn), | |
), | |
ExcludeSemantics( | |
child: Text( | |
localizations.formatMonthYear(utils.addMonthsToMonthDate( | |
widget.firstDate, _currentIndex)), | |
style: textTheme.bodyText2 | |
.apply(color: themeData.colorScheme.onSurface), | |
), | |
), | |
InkWell( | |
child: Icon( | |
Icons.arrow_forward_ios, | |
color: _currentIndex == _numberOfMonths - 1 | |
? Colors.black12 | |
: null, | |
size: 15, | |
), | |
onTap: () => _controller.nextPage( | |
duration: Duration(milliseconds: 500), | |
curve: Curves.fastLinearToSlowEaseIn), | |
), | |
], | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
), | |
), | |
Expanded( | |
child: _CalendarKeyboardNavigator( | |
firstDate: widget.firstDate, | |
lastDate: widget.lastDate, | |
initialFocusedDay: | |
_startDate ?? widget.initialStartDate ?? widget.currentDate, | |
child: PageView.builder( | |
controller: _controller, | |
onPageChanged: (index) => setState(() => _currentIndex = index), | |
itemBuilder: (context, index) { | |
if (index < _initialMonthIndex) { | |
return _buildMonthItem(context, index, true); | |
} | |
return _buildMonthItem(context, index, false); | |
}, | |
itemCount: _numberOfMonths, | |
)), | |
), | |
], | |
); | |
} | |
} | |
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 = utils.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 !utils.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 TextStyle textStyle = | |
themeData.textTheme.bodyText2.copyWith(color: Colors.black54, fontWeight: FontWeight.w300); | |
final MaterialLocalizations localizations = | |
MaterialLocalizations.of(context); | |
final List<Widget> labels = _getDayHeaders(textStyle, localizations); | |
return Container( | |
constraints: BoxConstraints( | |
maxHeight: _monthItemRowHeight, | |
), | |
child: Row( | |
children: labels, | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
), | |
); | |
} | |
} | |
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 = utils.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 && | |
utils.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 (utils.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 MaterialLocalizations localizations = | |
MaterialLocalizations.of(context); | |
final int year = widget.displayedMonth.year; | |
final int month = widget.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) && | |
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 Container( | |
constraints: BoxConstraints( | |
maxWidth: maxWidth, | |
maxHeight: gridHeight, | |
), | |
child: GridView.custom( | |
physics: const NeverScrollableScrollPhysics(), | |
gridDelegate: _monthItemGridDelegate, | |
childrenDelegate: SliverChildListDelegate( | |
paddedDayItems, | |
addRepaintBoundaries: false, | |
), | |
), | |
); | |
} | |
} | |
/// 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 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 | |
// 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. | |
import 'package:flutter/material.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, 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