// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flyinsonolite/controls/custom/customiconbutton.dart'; import 'package:flyinsonolite/infrastructure/scale.dart'; const Duration _monthScrollDuration = Duration(milliseconds: 200); double get _dayPickerRowHeight => 42.s; const int _maxDayPickerRowCount = 6; // A 31 day month that starts on Saturday. // One extra row for the day-of-week header. double get _maxDayPickerHeight => _dayPickerRowHeight * (_maxDayPickerRowCount + 1); double get _monthPickerHorizontalPadding => 8.s; const int _yearPickerColumnCount = 3; double get _yearPickerPadding => 16.s; double get _yearPickerRowHeight => 52.s; double get _yearPickerRowSpacing => 8.s; double get _subHeaderHeight => 52.s; double get _monthNavButtonsWidth => 108.s; /// Displays a grid of days for a given month and allows the user to select a /// date. /// /// Days are arranged in a rectangular grid with one column for each day of the /// week. Controls are provided to change the year and month that the grid is /// showing. /// /// The calendar picker widget is rarely used directly. Instead, consider using /// [showCustomDatePicker], which will create a dialog that uses this as well as /// provides a text entry option. /// /// See also: /// /// * [showCustomDatePicker], which creates a Dialog that contains a /// [CustomCalendarDatePicker] and provides an optional compact view where the /// user can enter a date as a line of text. /// * [showCustomDatePicker], which shows a dialog that contains a Material Design /// time picker. /// class CustomCalendarDatePicker extends StatefulWidget { /// Creates a calendar date picker. /// /// It will display a grid of days for the [initialDate]'s month. The day /// indicated by [initialDate] will be selected. /// /// The optional [onDisplayedMonthChanged] callback can be used to track /// the currently displayed month. /// /// The user interface provides a way to change the year of the month being /// displayed. By default it will show the day grid, but this can be changed /// to start in the year selection interface with [initialCalendarMode] set /// to [DatePickerMode.year]. /// /// The [initialDate], [firstDate], [lastDate], [onDateChanged], and /// [initialCalendarMode] must be non-null. /// /// [lastDate] must be after or equal to [firstDate]. /// /// [initialDate] must be between [firstDate] and [lastDate] or equal to /// one of them. /// /// [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. /// /// If [selectableDayPredicate] is non-null, it must return `true` for the /// [initialDate]. CustomCalendarDatePicker({ super.key, required DateTime initialDate, required DateTime firstDate, required DateTime lastDate, DateTime? currentDate, required this.onDateChanged, this.onDisplayedMonthChanged, this.initialCalendarMode = DatePickerMode.day, this.selectableDayPredicate, }) : assert(initialDate != null), assert(firstDate != null), assert(lastDate != null), initialDate = DateUtils.dateOnly(initialDate), firstDate = DateUtils.dateOnly(firstDate), lastDate = DateUtils.dateOnly(lastDate), currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), assert(onDateChanged != null), assert(initialCalendarMode != null) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', ); assert( !this.initialDate.isBefore(this.firstDate), 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.', ); assert( !this.initialDate.isAfter(this.lastDate), 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.', ); assert( selectableDayPredicate == null || selectableDayPredicate!(this.initialDate), 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate.', ); } /// The initially selected [DateTime] that the picker should display. final DateTime initialDate; /// 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 selects a date in the picker. final ValueChanged onDateChanged; /// Called when the user navigates to a new month/year in the picker. final ValueChanged? onDisplayedMonthChanged; /// The initial display of the calendar picker. final DatePickerMode initialCalendarMode; /// Function to provide full control over which dates in the calendar can be selected. final SelectableDayPredicate? selectableDayPredicate; @override State createState() => _CustomCalendarDatePickerState(); } class _CustomCalendarDatePickerState extends State { bool _announcedInitialDate = false; late DatePickerMode _mode; late DateTime _currentDisplayedMonthDate; late DateTime _selectedDate; final GlobalKey _monthPickerKey = GlobalKey(); final GlobalKey _yearPickerKey = GlobalKey(); late MaterialLocalizations _localizations; late TextDirection _textDirection; @override void initState() { super.initState(); _mode = widget.initialCalendarMode; _currentDisplayedMonthDate = DateTime(widget.initialDate.year, widget.initialDate.month); _selectedDate = widget.initialDate; } @override void didUpdateWidget(CustomCalendarDatePicker oldWidget) { super.didUpdateWidget(oldWidget); if (widget.initialCalendarMode != oldWidget.initialCalendarMode) { _mode = widget.initialCalendarMode; } if (!DateUtils.isSameDay(widget.initialDate, oldWidget.initialDate)) { _currentDisplayedMonthDate = DateTime(widget.initialDate.year, widget.initialDate.month); _selectedDate = widget.initialDate; } } @override void didChangeDependencies() { super.didChangeDependencies(); assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterialLocalizations(context)); assert(debugCheckHasDirectionality(context)); _localizations = MaterialLocalizations.of(context); _textDirection = Directionality.of(context); if (!_announcedInitialDate) { _announcedInitialDate = true; SemanticsService.announce( _localizations.formatFullDate(_selectedDate), _textDirection, ); } } void _vibrate() { switch (Theme.of(context).platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: HapticFeedback.vibrate(); break; case TargetPlatform.iOS: case TargetPlatform.macOS: break; } } void _handleModeChanged(DatePickerMode mode) { _vibrate(); setState(() { _mode = mode; if (_mode == DatePickerMode.day) { SemanticsService.announce( _localizations.formatMonthYear(_selectedDate), _textDirection, ); } else { SemanticsService.announce( _localizations.formatYear(_selectedDate), _textDirection, ); } }); } void _handleMonthChanged(DateTime date) { setState(() { if (_currentDisplayedMonthDate.year != date.year || _currentDisplayedMonthDate.month != date.month) { _currentDisplayedMonthDate = DateTime(date.year, date.month); widget.onDisplayedMonthChanged?.call(_currentDisplayedMonthDate); } }); } void _handleYearChanged(DateTime value) { _vibrate(); if (value.isBefore(widget.firstDate)) { value = widget.firstDate; } else if (value.isAfter(widget.lastDate)) { value = widget.lastDate; } setState(() { _mode = DatePickerMode.day; _handleMonthChanged(value); }); } void _handleDayChanged(DateTime value) { _vibrate(); setState(() { _selectedDate = value; widget.onDateChanged(_selectedDate); }); } Widget _buildPicker() { switch (_mode) { case DatePickerMode.day: return _MonthPicker( key: _monthPickerKey, initialMonth: _currentDisplayedMonthDate, currentDate: widget.currentDate, firstDate: widget.firstDate, lastDate: widget.lastDate, selectedDate: _selectedDate, onChanged: _handleDayChanged, onDisplayedMonthChanged: _handleMonthChanged, selectableDayPredicate: widget.selectableDayPredicate, ); case DatePickerMode.year: return Padding( padding: EdgeInsets.only(top: _subHeaderHeight), child: YearPicker( key: _yearPickerKey, currentDate: widget.currentDate, firstDate: widget.firstDate, lastDate: widget.lastDate, initialDate: _currentDisplayedMonthDate, selectedDate: _selectedDate, onChanged: _handleYearChanged, ), ); } } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterialLocalizations(context)); assert(debugCheckHasDirectionality(context)); return Stack( children: [ SizedBox( height: _subHeaderHeight + _maxDayPickerHeight, child: _buildPicker(), ), // Put the mode toggle button on top so that it won't be covered up by the _MonthPicker _DatePickerModeToggleButton( mode: _mode, title: _localizations.formatMonthYear(_currentDisplayedMonthDate), onTitlePressed: () { // Toggle the day/year mode. _handleModeChanged(_mode == DatePickerMode.day ? DatePickerMode.year : DatePickerMode.day); }, ), ], ); } } /// A button that used to toggle the [DatePickerMode] for a date picker. /// /// This appears above the calendar grid and allows the user to toggle the /// [DatePickerMode] to display either the calendar view or the year list. class _DatePickerModeToggleButton extends StatefulWidget { const _DatePickerModeToggleButton({ required this.mode, required this.title, required this.onTitlePressed, }); /// The current display of the calendar picker. final DatePickerMode mode; /// The text that displays the current month/year being viewed. final String title; /// The callback when the title is pressed. final VoidCallback onTitlePressed; @override _DatePickerModeToggleButtonState createState() => _DatePickerModeToggleButtonState(); } class _DatePickerModeToggleButtonState extends State<_DatePickerModeToggleButton> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( value: widget.mode == DatePickerMode.year ? 0.5 : 0, upperBound: 0.5, duration: const Duration(milliseconds: 200), vsync: this, ); } @override void didUpdateWidget(_DatePickerModeToggleButton oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.mode == widget.mode) { return; } if (widget.mode == DatePickerMode.year) { _controller.forward(); } else { _controller.reverse(); } } @override Widget build(BuildContext context) { final ColorScheme colorScheme = Theme.of(context).colorScheme; final TextTheme textTheme = Theme.of(context).textTheme; final Color controlColor = colorScheme.onSurface.withOpacity(0.60); return Container( padding: EdgeInsetsDirectional.only(start: 16.s, end: 4.s), height: _subHeaderHeight, child: Row( children: [ Flexible( child: Semantics( label: MaterialLocalizations.of(context).selectYearSemanticsLabel, excludeSemantics: true, button: true, child: SizedBox( height: _subHeaderHeight, child: InkWell( onTap: widget.onTitlePressed, child: Padding( padding: EdgeInsets.symmetric(horizontal: 8.s), child: Row( children: [ Flexible( child: Text( widget.title, overflow: TextOverflow.ellipsis, style: textTheme.titleSmall?.copyWith( color: controlColor, ), ), ), RotationTransition( turns: _controller, child: Icon( Icons.arrow_drop_down, color: controlColor, size: 24.s, ), ), ], ), ), ), ), ), ), if (widget.mode == DatePickerMode.day) // Give space for the prev/next month buttons that are underneath this row SizedBox(width: _monthNavButtonsWidth), ], ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } } class _MonthPicker extends StatefulWidget { /// Creates a month picker. _MonthPicker({ super.key, required this.initialMonth, required this.currentDate, required this.firstDate, required this.lastDate, required this.selectedDate, required this.onChanged, required this.onDisplayedMonthChanged, this.selectableDayPredicate, }) : assert(selectedDate != null), assert(currentDate != null), assert(onChanged != null), assert(firstDate != null), assert(lastDate != null), assert(!firstDate.isAfter(lastDate)), assert(!selectedDate.isBefore(firstDate)), assert(!selectedDate.isAfter(lastDate)); /// The initial month to display. final DateTime initialMonth; /// The current date. /// /// This date is subtly highlighted in the picker. final DateTime currentDate; /// The earliest date the user is permitted to pick. /// /// This date must be on or before the [lastDate]. final DateTime firstDate; /// The latest date the user is permitted to pick. /// /// This date must be on or after the [firstDate]. final DateTime lastDate; /// The currently selected date. /// /// This date is highlighted in the picker. final DateTime selectedDate; /// Called when the user picks a day. final ValueChanged onChanged; /// Called when the user navigates to a new month. final ValueChanged onDisplayedMonthChanged; /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate? selectableDayPredicate; @override _MonthPickerState createState() => _MonthPickerState(); } class _MonthPickerState extends State<_MonthPicker> { final GlobalKey _pageViewKey = GlobalKey(); late DateTime _currentMonth; late PageController _pageController; late MaterialLocalizations _localizations; late TextDirection _textDirection; Map? _shortcutMap; Map>? _actionMap; late FocusNode _dayGridFocus; DateTime? _focusedDay; @override void initState() { super.initState(); _currentMonth = widget.initialMonth; _pageController = PageController( initialPage: DateUtils.monthDelta(widget.firstDate, _currentMonth)); _shortcutMap = const { SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right), SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), }; _actionMap = >{ NextFocusIntent: CallbackAction(onInvoke: _handleGridNextFocus), PreviousFocusIntent: CallbackAction( onInvoke: _handleGridPreviousFocus), DirectionalFocusIntent: CallbackAction( onInvoke: _handleDirectionFocus), }; _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); } @override void didChangeDependencies() { super.didChangeDependencies(); _localizations = MaterialLocalizations.of(context); _textDirection = Directionality.of(context); } @override void didUpdateWidget(_MonthPicker oldWidget) { super.didUpdateWidget(oldWidget); if (widget.initialMonth != oldWidget.initialMonth && widget.initialMonth != _currentMonth) { // We can't interrupt this widget build with a scroll, so do it next frame WidgetsBinding.instance.addPostFrameCallback( (Duration timeStamp) => _showMonth(widget.initialMonth, jump: true), ); } } @override void dispose() { _pageController.dispose(); _dayGridFocus.dispose(); super.dispose(); } void _handleDateSelected(DateTime selectedDate) { _focusedDay = selectedDate; widget.onChanged(selectedDate); } void _handleMonthPageChanged(int monthPage) { setState(() { final DateTime monthDate = DateUtils.addMonthsToMonthDate(widget.firstDate, monthPage); if (!DateUtils.isSameMonth(_currentMonth, monthDate)) { _currentMonth = DateTime(monthDate.year, monthDate.month); widget.onDisplayedMonthChanged(_currentMonth); if (_focusedDay != null && !DateUtils.isSameMonth(_focusedDay, _currentMonth)) { // We have navigated to a new month with the grid focused, but the // focused day is not in this month. Choose a new one trying to keep // the same day of the month. _focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay!.day); } SemanticsService.announce( _localizations.formatMonthYear(_currentMonth), _textDirection, ); } }); } /// Returns a focusable date for the given month. /// /// If the preferredDay is available in the month it will be returned, /// otherwise the first selectable day in the month will be returned. If /// no dates are selectable in the month, then it will return null. DateTime? _focusableDayForMonth(DateTime month, int preferredDay) { final int daysInMonth = DateUtils.getDaysInMonth(month.year, month.month); // Can we use the preferred day in this month? if (preferredDay <= daysInMonth) { final DateTime newFocus = DateTime(month.year, month.month, preferredDay); if (_isSelectable(newFocus)) { return newFocus; } } // Start at the 1st and take the first selectable date. for (int day = 1; day <= daysInMonth; day++) { final DateTime newFocus = DateTime(month.year, month.month, day); if (_isSelectable(newFocus)) { return newFocus; } } return null; } /// Navigate to the next month. void _handleNextMonth() { if (!_isDisplayingLastMonth) { _pageController.nextPage( duration: _monthScrollDuration, curve: Curves.ease, ); } } /// Navigate to the previous month. void _handlePreviousMonth() { if (!_isDisplayingFirstMonth) { _pageController.previousPage( duration: _monthScrollDuration, curve: Curves.ease, ); } } /// Navigate to the given month. void _showMonth(DateTime month, {bool jump = false}) { final int monthPage = DateUtils.monthDelta(widget.firstDate, month); if (jump) { _pageController.jumpToPage(monthPage); } else { _pageController.animateToPage( monthPage, duration: _monthScrollDuration, curve: Curves.ease, ); } } /// True if the earliest allowable month is displayed. bool get _isDisplayingFirstMonth { return !_currentMonth.isAfter( DateTime(widget.firstDate.year, widget.firstDate.month), ); } /// True if the latest allowable month is displayed. bool get _isDisplayingLastMonth { return !_currentMonth.isBefore( DateTime(widget.lastDate.year, widget.lastDate.month), ); } /// Handler for when the overall day grid obtains or loses focus. void _handleGridFocusChange(bool focused) { setState(() { if (focused && _focusedDay == null) { if (DateUtils.isSameMonth(widget.selectedDate, _currentMonth)) { _focusedDay = widget.selectedDate; } else if (DateUtils.isSameMonth(widget.currentDate, _currentMonth)) { _focusedDay = _focusableDayForMonth(_currentMonth, widget.currentDate.day); } else { _focusedDay = _focusableDayForMonth(_currentMonth, 1); } } }); } /// 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; if (!DateUtils.isSameMonth(_focusedDay, _currentMonth)) { _showMonth(_focusedDay!); } } }); } static const Map _directionOffset = { 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); DateTime nextDate = DateUtils.addDaysToDate( date, _dayDirectionOffset(direction, textDirection)); while (!nextDate.isBefore(widget.firstDate) && !nextDate.isAfter(widget.lastDate)) { if (_isSelectable(nextDate)) { return nextDate; } nextDate = DateUtils.addDaysToDate( nextDate, _dayDirectionOffset(direction, textDirection)); } return null; } bool _isSelectable(DateTime date) { return widget.selectableDayPredicate == null || widget.selectableDayPredicate!.call(date); } Widget _buildItems(BuildContext context, int index) { final DateTime month = DateUtils.addMonthsToMonthDate(widget.firstDate, index); return _DayPicker( key: ValueKey(month), selectedDate: widget.selectedDate, currentDate: widget.currentDate, onChanged: _handleDateSelected, firstDate: widget.firstDate, lastDate: widget.lastDate, displayedMonth: month, selectableDayPredicate: widget.selectableDayPredicate, ); } @override Widget build(BuildContext context) { final Color controlColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.60); return Semantics( child: Column( children: [ Container( padding: EdgeInsetsDirectional.only(start: 16.s, end: 4.s), height: _subHeaderHeight, child: Row( children: [ const Spacer(), CustomIconButton( icon: const Icon( Icons.chevron_left, ), padding: EdgeInsets.all(8.s), iconSize: 30.s, constraints: const BoxConstraints(), color: controlColor, tooltip: _isDisplayingFirstMonth ? null : _localizations.previousMonthTooltip, onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth, ), CustomIconButton( icon: const Icon( Icons.chevron_right, ), padding: EdgeInsets.all(8.s), iconSize: 30.s, constraints: const BoxConstraints(), color: controlColor, tooltip: _isDisplayingLastMonth ? null : _localizations.nextMonthTooltip, onPressed: _isDisplayingLastMonth ? null : _handleNextMonth, ), ], ), ), Expanded( child: FocusableActionDetector( shortcuts: _shortcutMap, actions: _actionMap, focusNode: _dayGridFocus, onFocusChange: _handleGridFocusChange, child: _FocusedDate( date: _dayGridFocus.hasFocus ? _focusedDay : null, child: PageView.builder( key: _pageViewKey, controller: _pageController, itemBuilder: _buildItems, itemCount: DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1, onPageChanged: _handleMonthPageChanged, ), ), ), ), ], ), ); } } /// 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({ required super.child, this.date, }); final DateTime? date; @override bool updateShouldNotify(_FocusedDate oldWidget) { return !DateUtils.isSameDay(date, oldWidget.date); } static DateTime? of(BuildContext context) { final _FocusedDate? focusedDate = context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); return focusedDate?.date; } } /// Displays the days of a given month and allows choosing a day. /// /// The days are arranged in a rectangular grid with one column for each day of /// the week. class _DayPicker extends StatefulWidget { /// Creates a day picker. _DayPicker({ super.key, required this.currentDate, required this.displayedMonth, required this.firstDate, required this.lastDate, required this.selectedDate, required this.onChanged, this.selectableDayPredicate, }) : assert(currentDate != null), assert(displayedMonth != null), assert(firstDate != null), assert(lastDate != null), assert(selectedDate != null), assert(onChanged != null), assert(!firstDate.isAfter(lastDate)), assert(!selectedDate.isBefore(firstDate)), assert(!selectedDate.isAfter(lastDate)); /// The currently selected date. /// /// This date is highlighted in the picker. final DateTime selectedDate; /// The current date at the time the picker is displayed. final DateTime currentDate; /// Called when the user picks a day. final ValueChanged onChanged; /// The earliest date the user is permitted to pick. /// /// This date must be on or before the [lastDate]. final DateTime firstDate; /// The latest date the user is permitted to pick. /// /// This date must be on or after the [firstDate]. final DateTime lastDate; /// The month whose days are displayed by this picker. final DateTime displayedMonth; /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate? selectableDayPredicate; @override _DayPickerState createState() => _DayPickerState(); } class _DayPickerState extends State<_DayPicker> { /// List of [FocusNode]s, one for each day of the month. late List _dayFocusNodes; @override void initState() { super.initState(); final int daysInMonth = DateUtils.getDaysInMonth( widget.displayedMonth.year, widget.displayedMonth.month); _dayFocusNodes = List.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); if (focusedDate != null && DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { _dayFocusNodes[focusedDate.day - 1].requestFocus(); } } @override void dispose() { for (final FocusNode node in _dayFocusNodes) { node.dispose(); } super.dispose(); } /// 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 _dayHeaders( TextStyle? headerStyle, MaterialLocalizations localizations) { final List result = []; 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 ColorScheme colorScheme = Theme.of(context).colorScheme; final MaterialLocalizations localizations = MaterialLocalizations.of(context); final TextTheme textTheme = Theme.of(context).textTheme; final TextStyle? headerStyle = textTheme.bodySmall?.apply( color: colorScheme.onSurface.withOpacity(0.60), ); final TextStyle dayStyle = textTheme.bodySmall!; final Color enabledDayColor = colorScheme.onSurface.withOpacity(0.87); final Color disabledDayColor = colorScheme.onSurface.withOpacity(0.38); final Color selectedDayColor = colorScheme.onPrimary; final Color selectedDayBackground = colorScheme.primary; final Color todayColor = colorScheme.primary; final int year = widget.displayedMonth.year; final int month = widget.displayedMonth.month; final int daysInMonth = DateUtils.getDaysInMonth(year, month); final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); final List dayItems = _dayHeaders(headerStyle, localizations); // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on // a leap year. int day = -dayOffset; while (day < daysInMonth) { day++; if (day < 1) { dayItems.add(Container()); } else { final DateTime dayToBuild = DateTime(year, month, day); final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || dayToBuild.isBefore(widget.firstDate) || (widget.selectableDayPredicate != null && !widget.selectableDayPredicate!(dayToBuild)); final bool isSelectedDay = DateUtils.isSameDay(widget.selectedDate, dayToBuild); final bool isToday = DateUtils.isSameDay(widget.currentDate, dayToBuild); BoxDecoration? decoration; Color dayColor = enabledDayColor; if (isSelectedDay) { // The selected day gets a circle background highlight, and a // contrasting text color. dayColor = selectedDayColor; decoration = BoxDecoration( color: selectedDayBackground, shape: BoxShape.circle, ); } else if (isDisabled) { dayColor = disabledDayColor; } else if (isToday) { // The current day gets a different text color and a circle stroke // border. dayColor = todayColor; decoration = BoxDecoration( border: Border.all(color: todayColor, width: 1.s), shape: BoxShape.circle, ); } Widget dayWidget = Container( decoration: decoration, child: Center( child: Text(localizations.formatDecimal(day), style: dayStyle.apply(color: dayColor)), ), ); if (isDisabled) { dayWidget = ExcludeSemantics( child: dayWidget, ); } else { dayWidget = InkResponse( focusNode: _dayFocusNodes[day - 1], onTap: () => widget.onChanged(dayToBuild), radius: _dayPickerRowHeight / 2 + 4.s, splashColor: selectedDayBackground.withOpacity(0.38), child: Semantics( // 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. label: '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}', selected: isSelectedDay, excludeSemantics: true, child: dayWidget, ), ); } dayItems.add(dayWidget); } } return Padding( padding: EdgeInsets.symmetric( horizontal: _monthPickerHorizontalPadding, ), child: GridView.custom( physics: const ClampingScrollPhysics(), gridDelegate: _dayPickerGridDelegate, childrenDelegate: SliverChildListDelegate( dayItems, addRepaintBoundaries: false, ), ), ); } } class _DayPickerGridDelegate extends SliverGridDelegate { const _DayPickerGridDelegate(); @override SliverGridLayout getLayout(SliverConstraints constraints) { const int columnCount = DateTime.daysPerWeek; final double tileWidth = constraints.crossAxisExtent / columnCount; final double tileHeight = math.min( _dayPickerRowHeight, constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1), ); return SliverGridRegularTileLayout( childCrossAxisExtent: tileWidth, childMainAxisExtent: tileHeight, crossAxisCount: columnCount, crossAxisStride: tileWidth, mainAxisStride: tileHeight, reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), ); } @override bool shouldRelayout(_DayPickerGridDelegate oldDelegate) => false; } const _DayPickerGridDelegate _dayPickerGridDelegate = _DayPickerGridDelegate(); /// A scrollable grid of years to allow picking a year. /// /// The year picker widget is rarely used directly. Instead, consider using /// [CustomCalendarDatePicker], or [showCustomDatePicker] which create full date pickers. /// /// See also: /// /// * [CustomCalendarDatePicker], which provides a Material Design date picker /// interface. /// /// * [showCustomDatePicker], which shows a dialog containing a Material Design /// date picker. /// class YearPicker extends StatefulWidget { /// Creates a year picker. /// /// The [firstDate], [lastDate], [selectedDate], and [onChanged] /// arguments must be non-null. The [lastDate] must be after the [firstDate]. YearPicker({ super.key, DateTime? currentDate, required this.firstDate, required this.lastDate, DateTime? initialDate, required this.selectedDate, required this.onChanged, this.dragStartBehavior = DragStartBehavior.start, }) : assert(firstDate != null), assert(lastDate != null), assert(selectedDate != null), assert(onChanged != null), assert(!firstDate.isAfter(lastDate)), currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), initialDate = DateUtils.dateOnly(initialDate ?? selectedDate); /// The current date. /// /// This date is subtly highlighted in the picker. final DateTime currentDate; /// 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 initial date to center the year display around. final DateTime initialDate; /// The currently selected date. /// /// This date is highlighted in the picker. final DateTime selectedDate; /// Called when the user picks a year. final ValueChanged onChanged; /// {@macro flutter.widgets.scrollable.dragStartBehavior} final DragStartBehavior dragStartBehavior; @override State createState() => _YearPickerState(); } class _YearPickerState extends State { late ScrollController _scrollController; // The approximate number of years necessary to fill the available space. static const int minYears = 18; @override void initState() { super.initState(); _scrollController = ScrollController( initialScrollOffset: _scrollOffsetForYear(widget.selectedDate)); } @override void didUpdateWidget(YearPicker oldWidget) { super.didUpdateWidget(oldWidget); if (widget.selectedDate != oldWidget.selectedDate) { _scrollController.jumpTo(_scrollOffsetForYear(widget.selectedDate)); } } double _scrollOffsetForYear(DateTime date) { final int initialYearIndex = date.year - widget.firstDate.year; final int initialYearRow = initialYearIndex ~/ _yearPickerColumnCount; // Move the offset down by 2 rows to approximately center it. final int centeredYearRow = initialYearRow - 2; return _itemCount < minYears ? 0 : centeredYearRow * _yearPickerRowHeight; } Widget _buildYearItem(BuildContext context, int index) { final ColorScheme colorScheme = Theme.of(context).colorScheme; final TextTheme textTheme = Theme.of(context).textTheme; // Backfill the _YearPicker with disabled years if necessary. final int offset = _itemCount < minYears ? (minYears - _itemCount) ~/ 2 : 0; final int year = widget.firstDate.year + index - offset; final bool isSelected = year == widget.selectedDate.year; final bool isCurrentYear = year == widget.currentDate.year; final bool isDisabled = year < widget.firstDate.year || year > widget.lastDate.year; double decorationHeight = 36.s; double decorationWidth = 72.s; final Color textColor; if (isSelected) { textColor = colorScheme.onPrimary; } else if (isDisabled) { textColor = colorScheme.onSurface.withOpacity(0.38); } else if (isCurrentYear) { textColor = colorScheme.primary; } else { textColor = colorScheme.onSurface.withOpacity(0.87); } final TextStyle? itemStyle = textTheme.bodyLarge?.apply(color: textColor); BoxDecoration? decoration; if (isSelected) { decoration = BoxDecoration( color: colorScheme.primary, borderRadius: BorderRadius.circular(decorationHeight / 2), ); } else if (isCurrentYear && !isDisabled) { decoration = BoxDecoration( border: Border.all(color: colorScheme.primary, width: 1.s), borderRadius: BorderRadius.circular(decorationHeight / 2), ); } Widget yearItem = Center( child: Container( decoration: decoration, height: decorationHeight, width: decorationWidth, child: Center( child: Semantics( selected: isSelected, button: true, child: Text(year.toString(), style: itemStyle), ), ), ), ); if (isDisabled) { yearItem = ExcludeSemantics( child: yearItem, ); } else { yearItem = InkWell( key: ValueKey(year), onTap: () => widget.onChanged(DateTime(year, widget.initialDate.month)), child: yearItem, ); } return yearItem; } int get _itemCount { return widget.lastDate.year - widget.firstDate.year + 1; } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); return Column( children: [ const Divider(), Expanded( child: GridView.builder( controller: _scrollController, dragStartBehavior: widget.dragStartBehavior, gridDelegate: _yearPickerGridDelegate, itemBuilder: _buildYearItem, itemCount: math.max(_itemCount, minYears), padding: EdgeInsets.symmetric(horizontal: _yearPickerPadding), ), ), const Divider(), ], ); } } class _YearPickerGridDelegate extends SliverGridDelegate { const _YearPickerGridDelegate(); @override SliverGridLayout getLayout(SliverConstraints constraints) { final double tileWidth = (constraints.crossAxisExtent - (_yearPickerColumnCount - 1) * _yearPickerRowSpacing) / _yearPickerColumnCount; return SliverGridRegularTileLayout( childCrossAxisExtent: tileWidth, childMainAxisExtent: _yearPickerRowHeight, crossAxisCount: _yearPickerColumnCount, crossAxisStride: tileWidth + _yearPickerRowSpacing, mainAxisStride: _yearPickerRowHeight, reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), ); } @override bool shouldRelayout(_YearPickerGridDelegate oldDelegate) => false; } const _YearPickerGridDelegate _yearPickerGridDelegate = _YearPickerGridDelegate();