// 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' show DragStartBehavior; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../color_scheme.dart'; import '../divider.dart'; import '../ink_well.dart'; import '../material_localizations.dart'; import '../text_theme.dart'; import '../theme.dart'; import 'date_utils.dart' as utils; 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 ? 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> { 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 = utils.monthDelta(widget.firstDate, initialDate); } _showWeekBottomDivider = _initialMonthIndex != 0; } @override void dispose() { _controller.dispose(); super.dispose(); } void _scrollListener() { if (_controller.offset <= _controller.position.minScrollExtent) { setState(() { _showWeekBottomDivider = false; }); } else if (!_showWeekBottomDivider) { setState(() { _showWeekBottomDivider = true; }); } } int get _numberOfMonths => utils.monthDelta(widget.firstDate, widget.lastDate) + 1; void _vibrate() { switch (Theme.of(context).platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: HapticFeedback.vibrate(); break; default: break; } } // This updates the selected date range using this logic: // // * From the unselected state, selecting one date creates the start date. // * If the next selection is before the start date, reset date range and // set the start date to that selection. // * If the next selection is on or after the start date, set the end date // to that selection. // * After both start and end dates are selected, any subsequent selection // resets the date range and sets start date to that selection. void _updateSelection(DateTime date) { _vibrate(); setState(() { if (_startDate != null && _endDate == null && !date.isBefore(_startDate)) { _endDate = date; widget.onEndDateChanged?.call(_endDate); } else { _startDate = date; widget.onStartDateChanged?.call(_startDate); if (_endDate != null) { _endDate = null; widget.onEndDateChanged?.call(_endDate); } } }); } Widget _buildMonthItem(BuildContext context, int index, bool beforeInitialMonth) { final int monthIndex = beforeInitialMonth ? _initialMonthIndex - index - 1 : _initialMonthIndex + index; final DateTime month = utils.addMonthsToMonthDate(widget.firstDate, monthIndex); return _MonthItem( selectedDateStart: _startDate, selectedDateEnd: _endDate, currentDate: widget.currentDate, firstDate: widget.firstDate, lastDate: widget.lastDate, displayedMonth: month, onChanged: _updateSelection, ); } @override Widget build(BuildContext context) { const Key sliverAfterKey = Key('sliverAfterKey'); return Column( children: <Widget>[ _DayHeaders(), if (_showWeekBottomDivider) const Divider(height: 0), Expanded( 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 = 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 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 = 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 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 = 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 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; }