From 6d6d7914f99fcee9a6ae68829bd27997c8bff86b Mon Sep 17 00:00:00 2001 From: Sarbagya Dhaubanjar Date: Sat, 8 Mar 2025 08:26:17 +0545 Subject: [PATCH] Added calendar delegate to support custom calendar systems (#161874) Added `CalendarDelegate` class that supports plugging in custom calendar logics other than Gregorian Calendar System. Here is an example implementation for Nepali(Bikram Sambat) Calendar System: https://github.com/sarbagyastha/nepali_date_picker/blob/m3/lib/src/nepali_calendar_delegate.dart Demo using the `NepaliDatePickerDelegate`: https://date.sarbagyastha.com.np/ Fixes https://github.com/flutter/flutter/issues/77531, https://github.com/flutter/flutter/issues/161873 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --------- Co-authored-by: Tong Mu --- .../custom_calendar_date_picker.0.dart | 148 ++++++++++ .../custom_calendar_date_picker.0_test.dart | 45 +++ .../src/material/calendar_date_picker.dart | 164 +++++++---- packages/flutter/lib/src/material/date.dart | 257 +++++++++++++++++- .../flutter/lib/src/material/date_picker.dart | 175 ++++++++---- .../input_date_picker_form_field.dart | 16 +- .../material/calendar_date_picker_test.dart | 75 +++++ .../test/material/date_picker_test.dart | 94 +++++++ .../test/material/date_range_picker_test.dart | 109 ++++++++ .../input_date_picker_form_field_test.dart | 120 ++++++++ 10 files changed, 1095 insertions(+), 108 deletions(-) create mode 100644 examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart create mode 100644 examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart diff --git a/examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart b/examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart new file mode 100644 index 0000000000..1775062fec --- /dev/null +++ b/examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart @@ -0,0 +1,148 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample demonstrating how to use a custom [CalendarDelegate] +/// with [CalendarDatePicker] to implement a hypothetical calendar system +/// where even-numbered months have 21 days, odd-numbered months have 28 days, +/// and every month starts on a Monday. + +void main() => runApp(const CalendarDatePickerApp()); + +class CalendarDatePickerApp extends StatelessWidget { + const CalendarDatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CalendarDatePickerExample()); + } +} + +class CalendarDatePickerExample extends StatefulWidget { + const CalendarDatePickerExample({super.key}); + + @override + State createState() => _CalendarDatePickerExampleState(); +} + +class _CalendarDatePickerExampleState extends State { + DateTime? selectedDate; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom Calendar')), + body: Column( + spacing: 16, + children: [ + CalendarDatePicker( + initialDate: DateTime(2025, 2, 8), + firstDate: DateTime(2025), + lastDate: DateTime(2026), + onDateChanged: (DateTime pickedDate) { + setState(() { + selectedDate = pickedDate; + }); + }, + calendarDelegate: const CustomCalendarDelegate(), + ), + const Divider(height: 1), + Text( + selectedDate != null + ? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}' + : 'No date selected', + ), + ], + ), + ); + } +} + +/// A custom calendar system where even-numbered months have 21 days, +/// odd-numbered months have 28 days, and every month starts on a Monday. +/// +/// This hypothetical calendar follows a fixed structure: +/// - **Even-numbered months (2, 4, 6, etc.)** always have **21 days**. +/// - **Odd-numbered months (1, 3, 5, etc.)** always have **28 days**. +/// - **The first day of every month is always a Monday**, ensuring a consistent weekly alignment. +class CustomCalendarDelegate extends CalendarDelegate { + const CustomCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } + + // ------------------------------------------------------------------------ + // All the implementations below are based on the Gregorian calendar system. + + @override + DateTime now() => DateTime.now(); + + @override + DateTime dateOnly(DateTime date) => DateUtils.dateOnly(date); + + @override + int monthDelta(DateTime startDate, DateTime endDate) => DateUtils.monthDelta(startDate, endDate); + + @override + DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateUtils.addMonthsToMonthDate(monthDate, monthsToAdd); + } + + @override + DateTime addDaysToDate(DateTime date, int days) => DateUtils.addDaysToDate(date, days); + + @override + DateTime getMonth(int year, int month) => DateTime(year, month); + + @override + DateTime getDay(int year, int month, int day) => DateTime(year, month, day); + + @override + String formatMonthYear(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMonthYear(date); + } + + @override + String formatMediumDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMediumDate(date); + } + + @override + String formatShortMonthDay(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortMonthDay(date); + } + + @override + String formatShortDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortDate(date); + } + + @override + String formatFullDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatFullDate(date); + } + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatCompactDate(date); + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + return localizations.parseCompactDate(inputString); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return localizations.dateHelpText; + } +} diff --git a/examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart b/examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart new file mode 100644 index 0000000000..b34c41fca3 --- /dev/null +++ b/examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart @@ -0,0 +1,45 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/date_picker/custom_calendar_date_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Text getLastDayText(WidgetTester tester) { + final Finder dayFinder = find.descendant(of: find.byType(Ink), matching: find.byType(Text)); + return tester.widget(dayFinder.last); + } + + testWidgets('Days are based on the calendar delegate', (WidgetTester tester) async { + await tester.pumpWidget(const example.CalendarDatePickerApp()); + + final Finder nextMonthButton = find.byIcon(Icons.chevron_right); + + Text lastDayText = getLastDayText(tester); + expect(find.text('February 2025'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(find.text('March 2025'), findsOneWidget); + expect(lastDayText.data, equals('28')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(find.text('April 2025'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(lastDayText.data, equals('28')); + }); +} diff --git a/packages/flutter/lib/src/material/calendar_date_picker.dart b/packages/flutter/lib/src/material/calendar_date_picker.dart index 3dba92cda6..0fcc60bd59 100644 --- a/packages/flutter/lib/src/material/calendar_date_picker.dart +++ b/packages/flutter/lib/src/material/calendar_date_picker.dart @@ -107,6 +107,13 @@ class CalendarDatePicker extends StatefulWidget { /// /// If [selectableDayPredicate] and [initialDate] are both non-null, /// [selectableDayPredicate] must return `true` for the [initialDate]. + /// + /// {@template flutter.material.calendar_date_picker.calendarDelegate} + /// The [calendarDelegate] controls date interpretation, formatting, and + /// navigation within the picker. By providing a custom implementation, + /// you can support alternative calendar systems such as Nepali, Hijri, + /// Buddhist, and more. Defaults to [GregorianCalendarDelegate]. + /// {@endtemplate} CalendarDatePicker({ super.key, required DateTime? initialDate, @@ -117,10 +124,11 @@ class CalendarDatePicker extends StatefulWidget { this.onDisplayedMonthChanged, this.initialCalendarMode = DatePickerMode.day, this.selectableDayPredicate, - }) : initialDate = initialDate == null ? null : DateUtils.dateOnly(initialDate), - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()) { + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', @@ -175,6 +183,9 @@ class CalendarDatePicker extends StatefulWidget { /// Function to provide full control over which dates in the calendar can be selected. final SelectableDayPredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _CalendarDatePickerState(); } @@ -194,7 +205,10 @@ class _CalendarDatePickerState extends State { super.initState(); _mode = widget.initialCalendarMode; final DateTime currentDisplayedDate = widget.initialDate ?? widget.currentDate; - _currentDisplayedMonthDate = DateTime(currentDisplayedDate.year, currentDisplayedDate.month); + _currentDisplayedMonthDate = widget.calendarDelegate.getMonth( + currentDisplayedDate.year, + currentDisplayedDate.month, + ); if (widget.initialDate != null) { _selectedDate = widget.initialDate; } @@ -211,7 +225,7 @@ class _CalendarDatePickerState extends State { if (!_announcedInitialDate && widget.initialDate != null) { assert(_selectedDate != null); _announcedInitialDate = true; - final bool isToday = DateUtils.isSameDay(widget.currentDate, _selectedDate); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, _selectedDate); final String semanticLabelSuffix = isToday ? ', ${_localizations.currentDateLabel}' : ''; SemanticsService.announce( '${_localizations.formatFullDate(_selectedDate!)}$semanticLabelSuffix', @@ -239,8 +253,8 @@ class _CalendarDatePickerState extends State { _mode = mode; if (_selectedDate case final DateTime selected) { final String message = switch (mode) { - DatePickerMode.day => _localizations.formatMonthYear(selected), - DatePickerMode.year => _localizations.formatYear(selected), + DatePickerMode.day => widget.calendarDelegate.formatMonthYear(selected, _localizations), + DatePickerMode.year => widget.calendarDelegate.formatYear(selected.year, _localizations), }; SemanticsService.announce(message, _textDirection); } @@ -251,7 +265,7 @@ class _CalendarDatePickerState extends State { setState(() { if (_currentDisplayedMonthDate.year != date.year || _currentDisplayedMonthDate.month != date.month) { - _currentDisplayedMonthDate = DateTime(date.year, date.month); + _currentDisplayedMonthDate = widget.calendarDelegate.getMonth(date.year, date.month); widget.onDisplayedMonthChanged?.call(_currentDisplayedMonthDate); } }); @@ -260,9 +274,9 @@ class _CalendarDatePickerState extends State { void _handleYearChanged(DateTime value) { _vibrate(); - final int daysInMonth = DateUtils.getDaysInMonth(value.year, value.month); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(value.year, value.month); final int preferredDay = math.min(_selectedDate?.day ?? 1, daysInMonth); - value = value.copyWith(day: preferredDay); + value = widget.calendarDelegate.getDay(value.year, value.month, preferredDay); if (value.isBefore(widget.firstDate)) { value = widget.firstDate; @@ -290,10 +304,10 @@ class _CalendarDatePickerState extends State { case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: - final bool isToday = DateUtils.isSameDay(widget.currentDate, _selectedDate); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, _selectedDate); final String semanticLabelSuffix = isToday ? ', ${_localizations.currentDateLabel}' : ''; SemanticsService.announce( - '${_localizations.selectedDateLabel} ${_localizations.formatFullDate(_selectedDate!)}$semanticLabelSuffix', + '${_localizations.selectedDateLabel} ${widget.calendarDelegate.formatFullDate(_selectedDate!, _localizations)}$semanticLabelSuffix', _textDirection, ); case TargetPlatform.android: @@ -313,6 +327,7 @@ class _CalendarDatePickerState extends State { case DatePickerMode.day: return _MonthPicker( key: _monthPickerKey, + calendarDelegate: widget.calendarDelegate, initialMonth: _currentDisplayedMonthDate, currentDate: widget.currentDate, firstDate: widget.firstDate, @@ -327,6 +342,7 @@ class _CalendarDatePickerState extends State { padding: const EdgeInsets.only(top: _subHeaderHeight), child: YearPicker( key: _yearPickerKey, + calendarDelegate: widget.calendarDelegate, currentDate: widget.currentDate, firstDate: widget.firstDate, lastDate: widget.lastDate, @@ -363,7 +379,10 @@ class _CalendarDatePickerState extends State { maxScaleFactor: _kModeToggleButtonMaxScaleFactor, child: _DatePickerModeToggleButton( mode: _mode, - title: _localizations.formatMonthYear(_currentDisplayedMonthDate), + title: widget.calendarDelegate.formatMonthYear( + _currentDisplayedMonthDate, + _localizations, + ), onTitlePressed: () => _handleModeChanged(switch (_mode) { DatePickerMode.day => DatePickerMode.year, @@ -499,6 +518,7 @@ class _MonthPicker extends StatefulWidget { required this.selectedDate, required this.onChanged, required this.onDisplayedMonthChanged, + required this.calendarDelegate, this.selectableDayPredicate, }) : assert(!firstDate.isAfter(lastDate)), assert(selectedDate == null || !selectedDate.isBefore(firstDate)), @@ -541,6 +561,9 @@ class _MonthPicker extends StatefulWidget { /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _MonthPickerState createState() => _MonthPickerState(); } @@ -561,7 +584,7 @@ class _MonthPickerState extends State<_MonthPicker> { super.initState(); _currentMonth = widget.initialMonth; _pageController = PageController( - initialPage: DateUtils.monthDelta(widget.firstDate, _currentMonth), + initialPage: widget.calendarDelegate.monthDelta(widget.firstDate, _currentMonth), ); _shortcutMap = const { SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent( @@ -606,17 +629,24 @@ class _MonthPickerState extends State<_MonthPicker> { void _handleMonthPageChanged(int monthPage) { setState(() { - final DateTime monthDate = DateUtils.addMonthsToMonthDate(widget.firstDate, monthPage); - if (!DateUtils.isSameMonth(_currentMonth, monthDate)) { - _currentMonth = DateTime(monthDate.year, monthDate.month); + final DateTime monthDate = widget.calendarDelegate.addMonthsToMonthDate( + widget.firstDate, + monthPage, + ); + if (!widget.calendarDelegate.isSameMonth(_currentMonth, monthDate)) { + _currentMonth = widget.calendarDelegate.getMonth(monthDate.year, monthDate.month); widget.onDisplayedMonthChanged(_currentMonth); - if (_focusedDay != null && !DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + if (_focusedDay != null && + !widget.calendarDelegate.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); + SemanticsService.announce( + widget.calendarDelegate.formatMonthYear(_currentMonth, _localizations), + _textDirection, + ); } }); } @@ -627,11 +657,15 @@ class _MonthPickerState extends State<_MonthPicker> { /// 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); + final int daysInMonth = widget.calendarDelegate.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); + final DateTime newFocus = widget.calendarDelegate.getDay( + month.year, + month.month, + preferredDay, + ); if (_isSelectable(newFocus)) { return newFocus; } @@ -639,7 +673,7 @@ class _MonthPickerState extends State<_MonthPicker> { // 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); + final DateTime newFocus = widget.calendarDelegate.getDay(month.year, month.month, day); if (_isSelectable(newFocus)) { return newFocus; } @@ -663,7 +697,7 @@ class _MonthPickerState extends State<_MonthPicker> { /// Navigate to the given month. void _showMonth(DateTime month, {bool jump = false}) { - final int monthPage = DateUtils.monthDelta(widget.firstDate, month); + final int monthPage = widget.calendarDelegate.monthDelta(widget.firstDate, month); if (jump) { _pageController.jumpToPage(monthPage); } else { @@ -673,21 +707,25 @@ class _MonthPickerState extends State<_MonthPicker> { /// True if the earliest allowable month is displayed. bool get _isDisplayingFirstMonth { - return !_currentMonth.isAfter(DateTime(widget.firstDate.year, widget.firstDate.month)); + return !_currentMonth.isAfter( + widget.calendarDelegate.getMonth(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)); + return !_currentMonth.isBefore( + widget.calendarDelegate.getMonth(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)) { + if (widget.calendarDelegate.isSameMonth(widget.selectedDate, _currentMonth)) { _focusedDay = widget.selectedDate; - } else if (DateUtils.isSameMonth(widget.currentDate, _currentMonth)) { + } else if (widget.calendarDelegate.isSameMonth(widget.currentDate, _currentMonth)) { _focusedDay = _focusableDayForMonth(_currentMonth, widget.currentDate.day); } else { _focusedDay = _focusableDayForMonth(_currentMonth, 1); @@ -723,7 +761,7 @@ class _MonthPickerState extends State<_MonthPicker> { final DateTime? nextDate = _nextDateInDirection(_focusedDay!, intent.direction); if (nextDate != null) { _focusedDay = nextDate; - if (!DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + if (!widget.calendarDelegate.isSameMonth(_focusedDay, _currentMonth)) { _showMonth(_focusedDay!); } } @@ -751,7 +789,7 @@ class _MonthPickerState extends State<_MonthPicker> { DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { final TextDirection textDirection = Directionality.of(context); - DateTime nextDate = DateUtils.addDaysToDate( + DateTime nextDate = widget.calendarDelegate.addDaysToDate( date, _dayDirectionOffset(direction, textDirection), ); @@ -759,7 +797,10 @@ class _MonthPickerState extends State<_MonthPicker> { if (_isSelectable(nextDate)) { return nextDate; } - nextDate = DateUtils.addDaysToDate(nextDate, _dayDirectionOffset(direction, textDirection)); + nextDate = widget.calendarDelegate.addDaysToDate( + nextDate, + _dayDirectionOffset(direction, textDirection), + ); } return null; } @@ -769,9 +810,10 @@ class _MonthPickerState extends State<_MonthPicker> { } Widget _buildItems(BuildContext context, int index) { - final DateTime month = DateUtils.addMonthsToMonthDate(widget.firstDate, index); + final DateTime month = widget.calendarDelegate.addMonthsToMonthDate(widget.firstDate, index); return _DayPicker( key: ValueKey(month), + calendarDelegate: widget.calendarDelegate, selectedDate: widget.selectedDate, currentDate: widget.currentDate, onChanged: _handleDateSelected, @@ -821,12 +863,14 @@ class _MonthPickerState extends State<_MonthPicker> { focusNode: _dayGridFocus, onFocusChange: _handleGridFocusChange, child: _FocusedDate( + calendarDelegate: widget.calendarDelegate, date: _dayGridFocus.hasFocus ? _focusedDay : null, child: PageView.builder( key: _pageViewKey, controller: _pageController, itemBuilder: _buildItems, - itemCount: DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1, + itemCount: + widget.calendarDelegate.monthDelta(widget.firstDate, widget.lastDate) + 1, onPageChanged: _handleMonthPageChanged, ), ), @@ -843,13 +887,14 @@ class _MonthPickerState extends State<_MonthPicker> { /// 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}); + const _FocusedDate({required super.child, required this.calendarDelegate, this.date}); + final CalendarDelegate calendarDelegate; final DateTime? date; @override bool updateShouldNotify(_FocusedDate oldWidget) { - return !DateUtils.isSameDay(date, oldWidget.date); + return !calendarDelegate.isSameDay(date, oldWidget.date); } static DateTime? maybeOf(BuildContext context) { @@ -872,6 +917,7 @@ class _DayPicker extends StatefulWidget { required this.lastDate, required this.selectedDate, required this.onChanged, + required this.calendarDelegate, this.selectableDayPredicate, }) : assert(!firstDate.isAfter(lastDate)), assert(selectedDate == null || !selectedDate.isBefore(firstDate)), @@ -904,6 +950,9 @@ class _DayPicker extends StatefulWidget { /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _DayPickerState createState() => _DayPickerState(); } @@ -915,7 +964,7 @@ class _DayPickerState extends State<_DayPicker> { @override void initState() { super.initState(); - final int daysInMonth = DateUtils.getDaysInMonth( + final int daysInMonth = widget.calendarDelegate.getDaysInMonth( widget.displayedMonth.year, widget.displayedMonth.month, ); @@ -930,7 +979,8 @@ class _DayPickerState extends State<_DayPicker> { super.didChangeDependencies(); // Check to see if the focused date is in this month, if so focus it. final DateTime? focusedDate = _FocusedDate.maybeOf(context); - if (focusedDate != null && DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + if (focusedDate != null && + widget.calendarDelegate.isSameMonth(widget.displayedMonth, focusedDate)) { _dayFocusNodes[focusedDate.day - 1].requestFocus(); } } @@ -986,8 +1036,8 @@ class _DayPickerState extends State<_DayPicker> { 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 int daysInMonth = widget.calendarDelegate.getDaysInMonth(year, month); + final int dayOffset = widget.calendarDelegate.firstDayOffset(year, month, localizations); final List dayItems = _dayHeaders(weekdayStyle, localizations); // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on @@ -998,13 +1048,16 @@ class _DayPickerState extends State<_DayPicker> { if (day < 1) { dayItems.add(const SizedBox.shrink()); } else { - final DateTime dayToBuild = DateTime(year, month, day); + final DateTime dayToBuild = widget.calendarDelegate.getDay(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); + final bool isSelectedDay = widget.calendarDelegate.isSameDay( + widget.selectedDate, + dayToBuild, + ); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, dayToBuild); dayItems.add( _Day( @@ -1015,6 +1068,7 @@ class _DayPickerState extends State<_DayPicker> { isToday: isToday, onChanged: widget.onChanged, focusNode: _dayFocusNodes[day - 1], + calendarDelegate: widget.calendarDelegate, ), ); } @@ -1046,6 +1100,7 @@ class _Day extends StatefulWidget { required this.isToday, required this.onChanged, required this.focusNode, + required this.calendarDelegate, }); final DateTime day; @@ -1054,6 +1109,7 @@ class _Day extends StatefulWidget { final bool isToday; final ValueChanged onChanged; final FocusNode focusNode; + final CalendarDelegate calendarDelegate; @override State<_Day> createState() => _DayState(); @@ -1146,7 +1202,7 @@ class _DayState extends State<_Day> { // for the day of month. To do that we prepend day of month to the // formatted full date. label: - '${localizations.formatDecimal(widget.day.day)}, ${localizations.formatFullDate(widget.day)}$semanticLabelSuffix', + '${localizations.formatDecimal(widget.day.day)}, ${widget.calendarDelegate.formatFullDate(widget.day, localizations)}$semanticLabelSuffix', // Set button to true to make the date selectable. button: true, selected: widget.isSelectedDay, @@ -1232,8 +1288,9 @@ class YearPicker extends StatefulWidget { required this.selectedDate, required this.onChanged, this.dragStartBehavior = DragStartBehavior.start, + this.calendarDelegate = const GregorianCalendarDelegate(), }) : assert(!firstDate.isAfter(lastDate)), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()); + currentDate = calendarDelegate.dateOnly(currentDate ?? DateTime.now()); /// The current date. /// @@ -1257,6 +1314,9 @@ class YearPicker extends StatefulWidget { /// {@macro flutter.widgets.scrollable.dragStartBehavior} final DragStartBehavior dragStartBehavior; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _YearPickerState(); } @@ -1365,6 +1425,7 @@ class _YearPickerState extends State { final TextStyle? itemStyle = (datePickerTheme.yearStyle ?? defaults.yearStyle)?.apply( color: textColor, ); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); Widget yearItem = Center( child: Container( decoration: decoration, @@ -1374,7 +1435,7 @@ class _YearPickerState extends State { child: Semantics( selected: isSelected, button: true, - child: Text(year.toString(), style: itemStyle), + child: Text(widget.calendarDelegate.formatYear(year, localizations), style: itemStyle), ), ), ); @@ -1382,15 +1443,20 @@ class _YearPickerState extends State { if (isDisabled) { yearItem = ExcludeSemantics(child: yearItem); } else { - DateTime date = DateTime(year, widget.selectedDate?.month ?? DateTime.january); - if (date.isBefore(DateTime(widget.firstDate.year, widget.firstDate.month))) { + DateTime date = widget.calendarDelegate.getMonth( + year, + widget.selectedDate?.month ?? DateTime.january, + ); + if (date.isBefore( + widget.calendarDelegate.getMonth(widget.firstDate.year, widget.firstDate.month), + )) { // Ignore firstDate.day because we're just working in years and months here. assert(date.year == widget.firstDate.year); - date = DateTime(year, widget.firstDate.month); + date = widget.calendarDelegate.getMonth(year, widget.firstDate.month); } else if (date.isAfter(widget.lastDate)) { // No need to ignore the day here because it can only be bigger than what we care about. assert(date.year == widget.lastDate.year); - date = DateTime(year, widget.lastDate.month); + date = widget.calendarDelegate.getMonth(year, widget.lastDate.month); } _statesController.value = states; yearItem = InkWell( diff --git a/packages/flutter/lib/src/material/date.dart b/packages/flutter/lib/src/material/date.dart index 8fecc8ed91..4458d0cb8a 100644 --- a/packages/flutter/lib/src/material/date.dart +++ b/packages/flutter/lib/src/material/date.dart @@ -8,38 +8,279 @@ library; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'material_localizations.dart'; +/// Controls the calendar system used in the date picker. +/// +/// A [CalendarDelegate] defines how dates are interpreted, formatted, and +/// navigated within the picker. Different calendar systems (e.g., Gregorian, +/// Nepali, Hijri, Buddhist) can be supported by providing custom implementations. +/// +/// {@tool dartpad} +/// This example demonstrates how a [CalendarDelegate] is used to implement a +/// custom calendar system in the date picker. +/// +/// ** See code in examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [GregorianCalendarDelegate], the default implementation for the Gregorian calendar. +/// * [CalendarDatePicker], which uses this delegate to manage calendar-specific behavior. +abstract class CalendarDelegate { + /// Creates a calendar delegate. + const CalendarDelegate(); + + /// Returns a [DateTime] representing the current date and time. + T now(); + + /// {@macro flutter.material.date.dateOnly} + T dateOnly(T date); + + /// {@macro flutter.material.date.datesOnly} + DateTimeRange datesOnly(DateTimeRange range) { + return DateTimeRange(start: dateOnly(range.start), end: dateOnly(range.end)); + } + + /// {@macro flutter.material.date.isSameDay} + bool isSameDay(T? dateA, T? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month && dateA?.day == dateB?.day; + } + + /// {@macro flutter.material.date.isSameMonth} + bool isSameMonth(T? dateA, T? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month; + } + + /// {@macro flutter.material.date.monthDelta} + int monthDelta(T startDate, T endDate); + + /// {@macro flutter.material.date.addMonthsToMonthDate} + T addMonthsToMonthDate(T monthDate, int monthsToAdd); + + /// {@macro flutter.material.date.addDaysToDate} + T addDaysToDate(T date, int days); + + /// {@macro flutter.material.date.firstDayOffset} + int firstDayOffset(int year, int month, MaterialLocalizations localizations); + + /// Returns the number of days in a month, according to the calendar system. + int getDaysInMonth(int year, int month); + + /// Returns a [DateTime] with the given [year] and [month]. + T getMonth(int year, int month); + + /// Returns a [DateTime] with the given [year], [month], and [day]. + T getDay(int year, int month, int day); + + /// Formats the month and the year of the given [date]. + /// + /// The returned string does not contain the day of the month. This appears + /// in the date picker invoked using [showDatePicker]. + String formatMonthYear(T date, MaterialLocalizations localizations); + + /// Full unabbreviated year format, e.g. 2017 rather than 17. + String formatYear(int year, MaterialLocalizations localizations) { + return localizations.formatYear(DateTime(year)); + } + + /// Formats the date using a medium-width format. + /// + /// Abbreviates month and days of week. This appears in the header of the date + /// picker invoked using [showDatePicker]. + /// + /// Examples: + /// + /// - US English: Wed, Sep 27 + /// - Russian: ср, сент. 27 + String formatMediumDate(T date, MaterialLocalizations localizations); + + /// Formats the month and day of the given [date]. + /// + /// Examples: + /// + /// - US English: Feb 21 + /// - Russian: 21 февр. + String formatShortMonthDay(T date, MaterialLocalizations localizations); + + /// Formats the date using a short-width format. + /// + /// Includes the abbreviation of the month, the day and year. + /// + /// Examples: + /// + /// - US English: Feb 21, 2019 + /// - Russian: 21 февр. 2019 г. + String formatShortDate(T date, MaterialLocalizations localizations); + + /// Formats day of week, month, day of month and year in a long-width format. + /// + /// Does not abbreviate names. Appears in spoken announcements of the date + /// picker invoked using [showDatePicker], when accessibility mode is on. + /// + /// Examples: + /// + /// - US English: Wednesday, September 27, 2017 + /// - Russian: Среда, Сентябрь 27, 2017 + String formatFullDate(T date, MaterialLocalizations localizations); + + /// Formats the date in a compact format. + /// + /// Usually just the numeric values for the for day, month and year are used. + /// + /// Examples: + /// + /// - US English: 02/21/2019 + /// - Russian: 21.02.2019 + /// + /// See also: + /// * [parseCompactDate], which will convert a compact date string to a [DateTime]. + String formatCompactDate(T date, MaterialLocalizations localizations); + + /// Converts the given compact date formatted string into a [DateTime]. + /// + /// The format of the string must be a valid compact date format for the + /// given locale. If the text doesn't represent a valid date, `null` will be + /// returned. + /// + /// See also: + /// * [formatCompactDate], which will convert a [DateTime] into a string in the compact format. + T? parseCompactDate(String? inputString, MaterialLocalizations localizations); + + /// The help text used on an empty [InputDatePickerFormField] to indicate + /// to the user the date format being asked for. + String dateHelpText(MaterialLocalizations localizations); +} + +/// A [CalendarDelegate] implementation for the Gregorian calendar system. +/// +/// The Gregorian calendar is the most widely used civil calendar worldwide. +/// This delegate provides standard date interpretation, formatting, and +/// navigation based on the Gregorian system. +/// +/// This delegate is the default calendar system for [CalendarDatePicker]. +/// +/// See also: +/// * [CalendarDelegate], the base class for defining custom calendars. +/// * [CalendarDatePicker], which uses this delegate for date selection. +class GregorianCalendarDelegate extends CalendarDelegate { + /// Creates a calendar delegate that uses the Gregorian calendar and the + /// conventions of the current [MaterialLocalizations]. + const GregorianCalendarDelegate(); + + @override + DateTime now() => DateTime.now(); + + @override + DateTime dateOnly(DateTime date) => DateUtils.dateOnly(date); + + @override + int monthDelta(DateTime startDate, DateTime endDate) => DateUtils.monthDelta(startDate, endDate); + + @override + DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateUtils.addMonthsToMonthDate(monthDate, monthsToAdd); + } + + @override + DateTime addDaysToDate(DateTime date, int days) => DateUtils.addDaysToDate(date, days); + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return DateUtils.firstDayOffset(year, month, localizations); + } + + /// {@macro flutter.material.date.getDaysInMonth} + @override + int getDaysInMonth(int year, int month) => DateUtils.getDaysInMonth(year, month); + + @override + DateTime getMonth(int year, int month) => DateTime(year, month); + + @override + DateTime getDay(int year, int month, int day) => DateTime(year, month, day); + + @override + String formatMonthYear(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMonthYear(date); + } + + @override + String formatMediumDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMediumDate(date); + } + + @override + String formatShortMonthDay(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortMonthDay(date); + } + + @override + String formatShortDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortDate(date); + } + + @override + String formatFullDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatFullDate(date); + } + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatCompactDate(date); + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + return localizations.parseCompactDate(inputString); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return localizations.dateHelpText; + } +} + /// Utility functions for working with dates. abstract final class DateUtils { + /// {@template flutter.material.date.dateOnly} /// Returns a [DateTime] with the date of the original, but time set to /// midnight. + /// {@endtemplate} static DateTime dateOnly(DateTime date) { return DateTime(date.year, date.month, date.day); } + /// {@template flutter.material.date.datesOnly} /// Returns a [DateTimeRange] with the dates of the original, but with times /// set to midnight. /// /// See also: /// * [dateOnly], which does the same thing for a single date. + /// {@endtemplate} static DateTimeRange datesOnly(DateTimeRange range) { return DateTimeRange(start: dateOnly(range.start), end: dateOnly(range.end)); } + /// {@template flutter.material.date.isSameDay} /// Returns true if the two [DateTime] objects have the same day, month, and /// year, or are both null. + /// {@endtemplate} static bool isSameDay(DateTime? dateA, DateTime? dateB) { return dateA?.year == dateB?.year && dateA?.month == dateB?.month && dateA?.day == dateB?.day; } + /// {@template flutter.material.date.isSameMonth} /// Returns true if the two [DateTime] objects have the same month and /// year, or are both null. + /// {@endtemplate} static bool isSameMonth(DateTime? dateA, DateTime? dateB) { return dateA?.year == dateB?.year && dateA?.month == dateB?.month; } + /// {@template flutter.material.date.monthDelta} /// Determines the number of months between two [DateTime] objects. /// /// For example: @@ -51,10 +292,12 @@ abstract final class DateUtils { /// ``` /// /// The value for `delta` would be `7`. + /// {@endtemplate} static int monthDelta(DateTime startDate, DateTime endDate) { return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month; } + /// {@template flutter.material.date.addMonthsToMonthDate} /// Returns a [DateTime] that is [monthDate] with the added number /// of months and the day set to 1 and time set to midnight. /// @@ -67,16 +310,20 @@ abstract final class DateUtils { /// /// `date` would be January 15, 2019. /// `futureDate` would be April 1, 2019 since it adds 3 months. + /// {@endtemplate} static DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { return DateTime(monthDate.year, monthDate.month + monthsToAdd); } + /// {@template flutter.material.date.addDaysToDate} /// Returns a [DateTime] with the added number of days and time set to /// midnight. + /// {@endtemplate} static DateTime addDaysToDate(DateTime date, int days) { return DateTime(date.year, date.month, date.day + days); } + /// {@template flutter.material.date.firstDayOffset} /// Computes the offset from the first day of the week that the first day of /// the [month] falls on. /// @@ -105,6 +352,7 @@ abstract final class DateUtils { /// into the [MaterialLocalizations.narrowWeekdays] list. /// - [MaterialLocalizations.narrowWeekdays] list provides localized names of /// days of week, always starting with Sunday and ending with Saturday. + /// {@endtemplate} static 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; @@ -121,11 +369,13 @@ abstract final class DateUtils { return (weekdayFromMonday - firstDayOfWeekIndex) % 7; } + /// {@template flutter.material.date.getDaysInMonth} /// 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. + /// {@endtemplate} static int getDaysInMonth(int year, int month) { if (month == DateTime.february) { final bool isLeapYear = (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0); @@ -203,15 +453,16 @@ typedef SelectableDayPredicate = bool Function(DateTime day); /// * [showDateRangePicker], which displays a dialog that allows the user to /// select a date range. @immutable -class DateTimeRange { +@optionalTypeArgs +class DateTimeRange { /// Creates a date range for the given start and end [DateTime]. DateTimeRange({required this.start, required this.end}) : assert(!start.isAfter(end)); /// The start of the range of dates. - final DateTime start; + final T start; /// The end of the range of dates. - final DateTime end; + final T end; /// Returns a [Duration] of the time between [start] and [end]. /// diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index e7ccdfc18a..3b94e1720d 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -123,6 +123,8 @@ const double _fontSizeToScale = 14.0; /// this can be used to only allow weekdays for selection. If provided, it must /// return true for [initialDate]. /// +/// {@macro flutter.material.calendar_date_picker.calendarDelegate} +/// /// The following optional string parameters allow you to override the default /// text used for various parts of the dialog: /// @@ -221,10 +223,11 @@ Future showDatePicker({ final ValueChanged? onDatePickerModeChange, final Icon? switchToInputEntryModeIcon, final Icon? switchToCalendarEntryModeIcon, + final CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), }) async { - initialDate = initialDate == null ? null : DateUtils.dateOnly(initialDate); - firstDate = DateUtils.dateOnly(firstDate); - lastDate = DateUtils.dateOnly(lastDate); + initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate); + firstDate = calendarDelegate.dateOnly(firstDate); + lastDate = calendarDelegate.dateOnly(lastDate); assert( !lastDate.isBefore(firstDate), 'lastDate $lastDate must be on or after firstDate $firstDate.', @@ -262,6 +265,7 @@ Future showDatePicker({ onDatePickerModeChange: onDatePickerModeChange, switchToInputEntryModeIcon: switchToInputEntryModeIcon, switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + calendarDelegate: calendarDelegate, ); if (textDirection != null) { @@ -328,10 +332,11 @@ class DatePickerDialog extends StatefulWidget { this.switchToInputEntryModeIcon, this.switchToCalendarEntryModeIcon, this.insetPadding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0), - }) : initialDate = initialDate == null ? null : DateUtils.dateOnly(initialDate), - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()) { + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', @@ -453,6 +458,9 @@ class DatePickerDialog extends StatefulWidget { /// Defaults to `EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)`. final EdgeInsets insetPadding; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _DatePickerDialogState(); } @@ -623,6 +631,7 @@ class _DatePickerDialogState extends State with RestorationMix CalendarDatePicker calendarDatePicker() { return CalendarDatePicker( + calendarDelegate: widget.calendarDelegate, key: _calendarPickerKey, initialDate: _selectedDate.value, firstDate: widget.firstDate, @@ -654,6 +663,7 @@ class _DatePickerDialogState extends State with RestorationMix child: MediaQuery.withClampedTextScaling( maxScaleFactor: 2.0, child: InputDatePickerFormField( + calendarDelegate: widget.calendarDelegate, initialDate: _selectedDate.value, firstDate: widget.firstDate, lastDate: widget.lastDate, @@ -716,7 +726,9 @@ class _DatePickerDialogState extends State with RestorationMix ? localizations.datePickerHelpText : localizations.datePickerHelpText.toUpperCase()), titleText: - _selectedDate.value == null ? '' : localizations.formatMediumDate(_selectedDate.value!), + _selectedDate.value == null + ? '' + : widget.calendarDelegate.formatMediumDate(_selectedDate.value!, localizations), titleStyle: headlineStyle, orientation: orientation, isShort: orientation == Orientation.landscape, @@ -1076,6 +1088,8 @@ typedef SelectableDayForRangePredicate = /// /// {@macro flutter.material.date_picker.switchToCalendarEntryModeIcon} /// +/// {@macro flutter.material.calendar_date_picker.calendarDelegate} +/// /// The following optional string parameters allow you to override the default /// text used for various parts of the dialog: /// @@ -1173,10 +1187,11 @@ Future showDateRangePicker({ final Icon? switchToInputEntryModeIcon, final Icon? switchToCalendarEntryModeIcon, SelectableDayForRangePredicate? selectableDayPredicate, + CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), }) async { - initialDateRange = initialDateRange == null ? null : DateUtils.datesOnly(initialDateRange); - firstDate = DateUtils.dateOnly(firstDate); - lastDate = DateUtils.dateOnly(lastDate); + initialDateRange = initialDateRange == null ? null : calendarDelegate.datesOnly(initialDateRange); + firstDate = calendarDelegate.dateOnly(firstDate); + lastDate = calendarDelegate.dateOnly(lastDate); assert( !lastDate.isBefore(firstDate), 'lastDate $lastDate must be on or after firstDate $firstDate.', @@ -1213,7 +1228,7 @@ Future showDateRangePicker({ selectableDayPredicate(initialDateRange.end, initialDateRange.start, initialDateRange.end), "initialDateRange's end date must be selectable.", ); - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()); + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()); assert(debugCheckHasMaterialLocalizations(context)); Widget dialog = DateRangePickerDialog( @@ -1270,14 +1285,15 @@ Future showDateRangePicker({ /// (i.e. 'Jan 21, 2020'). String _formatRangeStartDate( MaterialLocalizations localizations, + CalendarDelegate calendarDelegate, DateTime? startDate, DateTime? endDate, ) { return startDate == null ? localizations.dateRangeStartLabel : (endDate == null || startDate.year == endDate.year) - ? localizations.formatShortMonthDay(startDate) - : localizations.formatShortDate(startDate); + ? calendarDelegate.formatShortMonthDay(startDate, localizations) + : calendarDelegate.formatShortDate(startDate, localizations); } /// Returns an locale-appropriate string to describe the end of a date range. @@ -1288,6 +1304,7 @@ String _formatRangeStartDate( /// include the year (i.e. 'Jan 21, 2020'). String _formatRangeEndDate( MaterialLocalizations localizations, + CalendarDelegate calendarDelegate, DateTime? startDate, DateTime? endDate, DateTime currentDate, @@ -1295,8 +1312,8 @@ String _formatRangeEndDate( return endDate == null ? localizations.dateRangeEndLabel : (startDate != null && startDate.year == endDate.year && startDate.year == currentDate.year) - ? localizations.formatShortMonthDay(endDate) - : localizations.formatShortDate(endDate); + ? calendarDelegate.formatShortMonthDay(endDate, localizations) + : calendarDelegate.formatShortDate(endDate, localizations); } /// A Material-style date range picker dialog. @@ -1333,6 +1350,7 @@ class DateRangePickerDialog extends StatefulWidget { this.switchToInputEntryModeIcon, this.switchToCalendarEntryModeIcon, this.selectableDayPredicate, + this.calendarDelegate = const GregorianCalendarDelegate(), }); /// The date range that the date range picker starts with when it opens. @@ -1464,6 +1482,9 @@ class DateRangePickerDialog extends StatefulWidget { /// Function to provide full control over which [DateTime] can be selected. final SelectableDayForRangePredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _DateRangePickerDialogState(); } @@ -1598,6 +1619,7 @@ class _DateRangePickerDialogState extends State with Rest case DatePickerEntryMode.calendarOnly: contents = _CalendarRangePickerDialog( key: _calendarPickerKey, + calendarDelegate: widget.calendarDelegate, selectedStartDate: _selectedStart.value, selectedEndDate: _selectedEnd.value, firstDate: widget.firstDate, @@ -1641,6 +1663,7 @@ class _DateRangePickerDialogState extends State with Rest case DatePickerEntryMode.input: case DatePickerEntryMode.inputOnly: contents = _InputDateRangePickerDialog( + calendarDelegate: widget.calendarDelegate, selectedStartDate: _selectedStart.value, selectedEndDate: _selectedEnd.value, currentDate: widget.currentDate, @@ -1656,6 +1679,7 @@ class _DateRangePickerDialogState extends State with Rest const Spacer(), _InputDateRangePicker( key: _inputPickerKey, + calendarDelegate: widget.calendarDelegate, initialStartDate: _selectedStart.value, initialEndDate: _selectedEnd.value, firstDate: widget.firstDate, @@ -1763,6 +1787,7 @@ class _CalendarRangePickerDialog extends StatelessWidget { required this.confirmText, required this.helpText, required this.selectableDayPredicate, + required this.calendarDelegate, this.entryModeButton, }); @@ -1778,6 +1803,7 @@ class _CalendarRangePickerDialog extends StatelessWidget { final VoidCallback? onCancel; final String confirmText; final String helpText; + final CalendarDelegate calendarDelegate; final Widget? entryModeButton; @override @@ -1802,14 +1828,16 @@ class _CalendarRangePickerDialog extends StatelessWidget { ?.apply(color: headerForeground); final String startDateText = _formatRangeStartDate( localizations, + calendarDelegate, selectedStartDate, selectedEndDate, ); final String endDateText = _formatRangeEndDate( localizations, + calendarDelegate, selectedStartDate, selectedEndDate, - DateTime.now(), + calendarDelegate.now(), ); final TextStyle? startDateStyle = headlineStyle?.apply( color: selectedStartDate != null ? headerForeground : headerDisabledForeground, @@ -1902,6 +1930,7 @@ class _CalendarRangePickerDialog extends StatelessWidget { onStartDateChanged: onStartDateChanged, onEndDateChanged: onEndDateChanged, selectableDayPredicate: selectableDayPredicate, + calendarDelegate: calendarDelegate, ), ), ); @@ -1931,11 +1960,13 @@ class _CalendarDateRangePicker extends StatefulWidget { DateTime? currentDate, required this.onStartDateChanged, required this.onEndDateChanged, - }) : initialStartDate = initialStartDate != null ? DateUtils.dateOnly(initialStartDate) : null, - initialEndDate = initialEndDate != null ? DateUtils.dateOnly(initialEndDate) : null, - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()) { + required this.calendarDelegate, + }) : initialStartDate = + initialStartDate != null ? calendarDelegate.dateOnly(initialStartDate) : null, + initialEndDate = initialEndDate != null ? calendarDelegate.dateOnly(initialEndDate) : null, + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { assert( this.initialStartDate == null || this.initialEndDate == null || @@ -1969,6 +2000,9 @@ class _CalendarDateRangePicker extends StatefulWidget { /// Called when the user changes the end date of the selected range. final ValueChanged? onEndDateChanged; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State<_CalendarDateRangePicker> createState() => _CalendarDateRangePickerState(); } @@ -1994,7 +2028,7 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { // divide the list of months into two `SliverList`s. final DateTime initialDate = widget.initialStartDate ?? widget.currentDate; if (!initialDate.isBefore(widget.firstDate) && !initialDate.isAfter(widget.lastDate)) { - _initialMonthIndex = DateUtils.monthDelta(widget.firstDate, initialDate); + _initialMonthIndex = widget.calendarDelegate.monthDelta(widget.firstDate, initialDate); } _showWeekBottomDivider = _initialMonthIndex != 0; @@ -2018,7 +2052,8 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { } } - int get _numberOfMonths => DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1; + int get _numberOfMonths => + widget.calendarDelegate.monthDelta(widget.firstDate, widget.lastDate) + 1; void _vibrate() { switch (Theme.of(context).platform) { @@ -2062,8 +2097,12 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { Widget _buildMonthItem(BuildContext context, int index, bool beforeInitialMonth) { final int monthIndex = beforeInitialMonth ? _initialMonthIndex - index - 1 : _initialMonthIndex + index; - final DateTime month = DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex); + final DateTime month = widget.calendarDelegate.addMonthsToMonthDate( + widget.firstDate, + monthIndex, + ); return _MonthItem( + calendarDelegate: widget.calendarDelegate, selectedDateStart: _startDate, selectedDateEnd: _endDate, currentDate: widget.currentDate, @@ -2085,6 +2124,7 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { if (_showWeekBottomDivider) const Divider(height: 0), Expanded( child: _CalendarKeyboardNavigator( + calendarDelegate: widget.calendarDelegate, firstDate: widget.firstDate, lastDate: widget.lastDate, initialFocusedDay: _startDate ?? widget.initialStartDate ?? widget.currentDate, @@ -2125,12 +2165,14 @@ class _CalendarKeyboardNavigator extends StatefulWidget { required this.firstDate, required this.lastDate, required this.initialFocusedDay, + required this.calendarDelegate, }); final Widget child; final DateTime firstDate; final DateTime lastDate; final DateTime initialFocusedDay; + final CalendarDelegate calendarDelegate; @override _CalendarKeyboardNavigatorState createState() => _CalendarKeyboardNavigatorState(); @@ -2231,7 +2273,7 @@ class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { final TextDirection textDirection = Directionality.of(context); - final DateTime nextDate = DateUtils.addDaysToDate( + final DateTime nextDate = widget.calendarDelegate.addDaysToDate( date, _dayDirectionOffset(direction, textDirection), ); @@ -2249,6 +2291,7 @@ class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> focusNode: _dayGridFocus, onFocusChange: _handleGridFocusChange, child: _FocusedDate( + calendarDelegate: widget.calendarDelegate, date: _dayGridFocus.hasFocus ? _focusedDay : null, scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null, child: widget.child, @@ -2260,14 +2303,20 @@ class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> /// InheritedWidget indicating what the current focused date is for its children. // See also: _FocusedDate in calendar_date_picker.dart class _FocusedDate extends InheritedWidget { - const _FocusedDate({required super.child, this.date, this.scrollDirection}); + const _FocusedDate({ + required super.child, + required this.calendarDelegate, + this.date, + this.scrollDirection, + }); + final CalendarDelegate calendarDelegate; final DateTime? date; final TraversalDirection? scrollDirection; @override bool updateShouldNotify(_FocusedDate oldWidget) { - return !DateUtils.isSameDay(date, oldWidget.date) || + return !calendarDelegate.isSameDay(date, oldWidget.date) || scrollDirection != oldWidget.scrollDirection; } @@ -2464,6 +2513,7 @@ class _MonthItem extends StatefulWidget { required this.lastDate, required this.displayedMonth, required this.selectableDayPredicate, + required this.calendarDelegate, }) : assert(!firstDate.isAfter(lastDate)), assert(selectedDateStart == null || !selectedDateStart.isBefore(firstDate)), assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)), @@ -2502,6 +2552,9 @@ class _MonthItem extends StatefulWidget { final SelectableDayForRangePredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _MonthItemState createState() => _MonthItemState(); } @@ -2513,7 +2566,7 @@ class _MonthItemState extends State<_MonthItem> { @override void initState() { super.initState(); - final int daysInMonth = DateUtils.getDaysInMonth( + final int daysInMonth = widget.calendarDelegate.getDaysInMonth( widget.displayedMonth.year, widget.displayedMonth.month, ); @@ -2528,7 +2581,8 @@ class _MonthItemState extends State<_MonthItem> { super.didChangeDependencies(); // Check to see if the focused date is in this month, if so focus it. final DateTime? focusedDate = _FocusedDate.maybeOf(context)?.date; - if (focusedDate != null && DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + if (focusedDate != null && + widget.calendarDelegate.isSameMonth(widget.displayedMonth, focusedDate)) { _dayFocusNodes[focusedDate.day - 1].requestFocus(); } } @@ -2596,9 +2650,10 @@ class _MonthItemState extends State<_MonthItem> { dayToBuild.isBefore(widget.selectedDateEnd!); final bool isOneDayRange = isRangeSelected && widget.selectedDateStart == widget.selectedDateEnd; - final bool isToday = DateUtils.isSameDay(widget.currentDate, dayToBuild); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, dayToBuild); return _DayItem( + calendarDelegate: widget.calendarDelegate, day: dayToBuild, focusNode: _dayFocusNodes[day - 1], onChanged: widget.onChanged, @@ -2626,8 +2681,8 @@ class _MonthItemState extends State<_MonthItem> { final MaterialLocalizations localizations = MaterialLocalizations.of(context); 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 int daysInMonth = widget.calendarDelegate.getDaysInMonth(year, month); + final int dayOffset = widget.calendarDelegate.firstDayOffset(year, month, localizations); final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); final double gridHeight = weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows; @@ -2639,7 +2694,7 @@ class _MonthItemState extends State<_MonthItem> { if (day < 1) { dayItems.add(const LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand())); } else { - final DateTime dayToBuild = DateTime(year, month, day); + final DateTime dayToBuild = widget.calendarDelegate.getDay(year, month, day); final Widget dayItem = _buildDayItem(context, dayToBuild, dayOffset, daysInMonth); dayItems.add(dayItem); } @@ -2653,7 +2708,11 @@ class _MonthItemState extends State<_MonthItem> { final int end = math.min(start + DateTime.daysPerWeek, dayItems.length); final List weekList = dayItems.sublist(start, end); - final DateTime dateAfterLeadingPadding = DateTime(year, month, start - dayOffset + 1); + final DateTime dateAfterLeadingPadding = widget.calendarDelegate.getDay( + 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 = @@ -2668,7 +2727,11 @@ class _MonthItemState extends State<_MonthItem> { // partial week. if (end < dayItems.length || (end == dayItems.length && dayItems.length % DateTime.daysPerWeek == 0)) { - final DateTime dateBeforeTrailingPadding = DateTime(year, month, end - dayOffset); + final DateTime dateBeforeTrailingPadding = widget.calendarDelegate.getDay( + 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 = @@ -2696,7 +2759,7 @@ class _MonthItemState extends State<_MonthItem> { alignment: AlignmentDirectional.centerStart, child: ExcludeSemantics( child: Text( - localizations.formatMonthYear(widget.displayedMonth), + widget.calendarDelegate.formatMonthYear(widget.displayedMonth, localizations), style: textTheme.bodyMedium!.apply(color: themeData.colorScheme.onSurface), ), ), @@ -2731,6 +2794,7 @@ class _DayItem extends StatefulWidget { required this.isInRange, required this.isOneDayRange, required this.isToday, + required this.calendarDelegate, }); final DateTime day; @@ -2757,6 +2821,8 @@ class _DayItem extends StatefulWidget { final bool isToday; + final CalendarDelegate calendarDelegate; + @override State<_DayItem> createState() => _DayItemState(); } @@ -2872,7 +2938,7 @@ class _DayItemState extends State<_DayItem> { // formatted full date. final String semanticLabelSuffix = widget.isToday ? ', ${localizations.currentDateLabel}' : ''; String semanticLabel = - '$dayText, ${localizations.formatFullDate(widget.day)}$semanticLabelSuffix'; + '$dayText, ${widget.calendarDelegate.formatFullDate(widget.day, localizations)}$semanticLabelSuffix'; if (widget.isSelectedDayStart) { semanticLabel = localizations.dateRangeStartDateSemanticLabel(semanticLabel); } else if (widget.isSelectedDayEnd) { @@ -2987,6 +3053,7 @@ class _InputDateRangePickerDialog extends StatelessWidget { required this.cancelText, required this.helpText, required this.entryModeButton, + required this.calendarDelegate, }); final DateTime? selectedStartDate; @@ -2999,11 +3066,12 @@ class _InputDateRangePickerDialog extends StatelessWidget { final String? cancelText; final String? helpText; final Widget? entryModeButton; + final CalendarDelegate calendarDelegate; String _formatDateRange(BuildContext context, DateTime? start, DateTime? end, DateTime now) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final String startText = _formatRangeStartDate(localizations, start, end); - final String endText = _formatRangeEndDate(localizations, start, end, now); + final String startText = _formatRangeStartDate(localizations, calendarDelegate, start, end); + final String endText = _formatRangeEndDate(localizations, calendarDelegate, start, end, now); if (start == null || end == null) { return localizations.unspecifiedDateRange; } @@ -3042,7 +3110,7 @@ class _InputDateRangePickerDialog extends StatelessWidget { ); final String semanticDateText = selectedStartDate != null && selectedEndDate != null - ? '${localizations.formatMediumDate(selectedStartDate!)} – ${localizations.formatMediumDate(selectedEndDate!)}' + ? '${calendarDelegate.formatMediumDate(selectedStartDate!, localizations)} – ${calendarDelegate.formatMediumDate(selectedEndDate!, localizations)}' : ''; final Widget header = _DatePickerHeader( @@ -3149,6 +3217,7 @@ class _InputDateRangePicker extends StatefulWidget { required this.onStartDateChanged, required this.onEndDateChanged, required this.selectableDayPredicate, + required this.calendarDelegate, this.helpText, this.errorFormatText, this.errorInvalidText, @@ -3160,10 +3229,11 @@ class _InputDateRangePicker extends StatefulWidget { this.autofocus = false, this.autovalidate = false, this.keyboardType = TextInputType.datetime, - }) : initialStartDate = initialStartDate == null ? null : DateUtils.dateOnly(initialStartDate), - initialEndDate = initialEndDate == null ? null : DateUtils.dateOnly(initialEndDate), - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate); + }) : initialStartDate = + initialStartDate == null ? null : calendarDelegate.dateOnly(initialStartDate), + initialEndDate = initialEndDate == null ? null : calendarDelegate.dateOnly(initialEndDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate); /// The [DateTime] that represents the start of the initial date range selection. final DateTime? initialStartDate; @@ -3224,6 +3294,9 @@ class _InputDateRangePicker extends StatefulWidget { final SelectableDayForRangePredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _InputDateRangePickerState createState() => _InputDateRangePickerState(); } @@ -3262,14 +3335,14 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { super.didChangeDependencies(); final MaterialLocalizations localizations = MaterialLocalizations.of(context); if (_startDate != null) { - _startInputText = localizations.formatCompactDate(_startDate!); + _startInputText = widget.calendarDelegate.formatCompactDate(_startDate!, localizations); final bool selectText = widget.autofocus && !_autoSelected; _updateController(_startController, _startInputText, selectText); _autoSelected = selectText; } if (_endDate != null) { - _endInputText = localizations.formatCompactDate(_endDate!); + _endInputText = widget.calendarDelegate.formatCompactDate(_endDate!, localizations); _updateController(_endController, _endInputText, false); } } @@ -3298,7 +3371,7 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { DateTime? _parseDate(String? text) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - return localizations.parseCompactDate(text); + return widget.calendarDelegate.parseCompactDate(text, localizations); } String? _validateDate(DateTime? date) { @@ -3371,7 +3444,8 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { decoration: InputDecoration( border: inputBorder, filled: inputTheme.filled, - hintText: widget.fieldStartHintText ?? localizations.dateHelpText, + hintText: + widget.fieldStartHintText ?? widget.calendarDelegate.dateHelpText(localizations), labelText: widget.fieldStartLabelText ?? localizations.dateRangeStartLabel, errorText: _startErrorText, ), @@ -3387,7 +3461,8 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { decoration: InputDecoration( border: inputBorder, filled: inputTheme.filled, - hintText: widget.fieldEndHintText ?? localizations.dateHelpText, + hintText: + widget.fieldEndHintText ?? widget.calendarDelegate.dateHelpText(localizations), labelText: widget.fieldEndLabelText ?? localizations.dateRangeEndLabel, errorText: _endErrorText, ), diff --git a/packages/flutter/lib/src/material/input_date_picker_form_field.dart b/packages/flutter/lib/src/material/input_date_picker_form_field.dart index 6f34b64acd..a7623e6f67 100644 --- a/packages/flutter/lib/src/material/input_date_picker_form_field.dart +++ b/packages/flutter/lib/src/material/input_date_picker_form_field.dart @@ -62,9 +62,10 @@ class InputDatePickerFormField extends StatefulWidget { this.autofocus = false, this.acceptEmptyDate = false, this.focusNode, - }) : initialDate = initialDate != null ? DateUtils.dateOnly(initialDate) : null, - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate) { + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate != null ? calendarDelegate.dateOnly(initialDate) : null, + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', @@ -146,6 +147,9 @@ class InputDatePickerFormField extends StatefulWidget { /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _InputDatePickerFormFieldState(); } @@ -191,7 +195,7 @@ class _InputDatePickerFormFieldState extends State { void _updateValueForSelectedDate() { if (_selectedDate != null) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - _inputText = localizations.formatCompactDate(_selectedDate!); + _inputText = widget.calendarDelegate.formatCompactDate(_selectedDate!, localizations); TextEditingValue textEditingValue = TextEditingValue(text: _inputText!); // Select the new text if we are auto focused and haven't selected the text before. if (widget.autofocus && !_autoSelected) { @@ -209,7 +213,7 @@ class _InputDatePickerFormFieldState extends State { DateTime? _parseDate(String? text) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - return localizations.parseCompactDate(text); + return widget.calendarDelegate.parseCompactDate(text, localizations); } bool _isValidAcceptableDate(DateTime? date) { @@ -265,7 +269,7 @@ class _InputDatePickerFormFieldState extends State { container: true, child: TextFormField( decoration: InputDecoration( - hintText: widget.fieldHintText ?? localizations.dateHelpText, + hintText: widget.fieldHintText ?? widget.calendarDelegate.dateHelpText(localizations), labelText: widget.fieldLabelText ?? localizations.dateInputLabel, ).applyDefaults( inputTheme diff --git a/packages/flutter/test/material/calendar_date_picker_test.dart b/packages/flutter/test/material/calendar_date_picker_test.dart index 19486bf43e..0af4a8ca28 100644 --- a/packages/flutter/test/material/calendar_date_picker_test.dart +++ b/packages/flutter/test/material/calendar_date_picker_test.dart @@ -1493,4 +1493,79 @@ void main() { expect(selectedYear, equals(DateTime(2018, DateTime.june))); }); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: CalendarDatePicker( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2025, DateTime.may), + onDateChanged: (DateTime value) {}, + ), + ), + ), + ); + + final CalendarDatePicker calendarPicker = tester.widget(find.byType(CalendarDatePicker)); + expect(calendarPicker.calendarDelegate, isA()); + + final Finder datePickerModeToggleButton = find.descendant( + of: find.byType(InkWell), + matching: find.text('February 2025'), + ); + await tester.tap(datePickerModeToggleButton); + await tester.pumpAndSettle(); + + final YearPicker yearPicker = tester.widget(find.byType(YearPicker)); + expect(yearPicker.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: CalendarDatePicker( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2025, DateTime.may), + onDateChanged: (DateTime value) {}, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final CalendarDatePicker calendarPicker = tester.widget(find.byType(CalendarDatePicker)); + expect(calendarPicker.calendarDelegate, isA()); + + final Finder datePickerModeToggleButton = find.descendant( + of: find.byType(InkWell), + matching: find.text('February 2025'), + ); + await tester.tap(datePickerModeToggleButton); + await tester.pumpAndSettle(); + + final YearPicker yearPicker = tester.widget(find.byType(YearPicker)); + expect(yearPicker.calendarDelegate, isA()); + }); + }); +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } } diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index 9c00c6158f..dc643e80d2 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -2636,6 +2636,86 @@ void main() { await gesture.up(); }); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final DatePickerDialog dialog = tester.widget(find.byType(DatePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final DatePickerDialog dialog = tester.widget(find.byType(DatePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + Text getLastDayText() { + final Finder dayFinder = find.descendant(of: find.byType(Ink), matching: find.byType(Text)); + return tester.widget(dayFinder.last); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder nextMonthButton = find.byIcon(Icons.chevron_right); + + Text lastDayText = getLastDayText(); + expect(find.text('January 2016'), findsOneWidget); + expect(lastDayText.data, equals('28')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(); + expect(find.text('February 2016'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(); + expect(find.text('March 2016'), findsOneWidget); + expect(lastDayText.data, equals('28')); + }); + }); } class _RestorableDatePickerDialogTestWidget extends StatefulWidget { @@ -2753,3 +2833,17 @@ class _DatePickerObserver extends NavigatorObserver { super.didPop(route, previousRoute); } } + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/flutter/test/material/date_range_picker_test.dart b/packages/flutter/test/material/date_range_picker_test.dart index 78233fcd25..ce876fe84c 100644 --- a/packages/flutter/test/material/date_range_picker_test.dart +++ b/packages/flutter/test/material/date_range_picker_test.dart @@ -1807,6 +1807,101 @@ void main() { }); }); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + Finder getMonthItem() { + final Finder dayItem = find.descendant( + of: find.byType(ConstrainedBox), + matching: find.text('1'), + ); + return find.ancestor(of: dayItem, matching: find.byType(Column)); + } + + int getDayCount(Finder parent) { + final Finder dayItem = find.descendant( + of: parent, + matching: find.descendant(of: find.byType(InkResponse), matching: find.byType(Text)), + ); + return tester.widgetList(dayItem).length; + } + + Text getMonthYear(Finder parent) { + return tester.widget( + find + .descendant( + of: parent, + matching: find.descendant( + of: find.byType(ConstrainedBox), + matching: find.byType(Text), + ), + ) + .first, + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder monthItem = getMonthItem(); + + final Finder firstMonthItem = monthItem.at(0); + expect(getMonthYear(firstMonthItem).data, 'January 2016'); + expect(getDayCount(firstMonthItem), 28); + + final Finder secondMonthItem = monthItem.at(2); + expect(getMonthYear(secondMonthItem).data, 'February 2016'); + expect(getDayCount(secondMonthItem), 21); + }); + }); } class _RestorableDateRangePickerDialogTestWidget extends StatefulWidget { @@ -1908,3 +2003,17 @@ class _RestorableDateRangePickerDialogTestWidgetState ); } } + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/flutter/test/material/input_date_picker_form_field_test.dart b/packages/flutter/test/material/input_date_picker_form_field_test.dart index ca7ce6b402..b57de89ad4 100644 --- a/packages/flutter/test/material/input_date_picker_form_field_test.dart +++ b/packages/flutter/test/material/input_date_picker_form_field_test.dart @@ -413,4 +413,124 @@ void main() { await tester.pumpAndSettle(); expect(focusNode.hasFocus, isFalse); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + ), + ), + ), + ); + + final InputDatePickerFormField inputDatePickerField = tester.widget( + find.byType(InputDatePickerFormField), + ); + expect(inputDatePickerField.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final InputDatePickerFormField inputDatePickerField = tester.widget( + find.byType(InputDatePickerFormField), + ); + expect(inputDatePickerField.calendarDelegate, isA()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + DateTime? selectedDate; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + onDateSubmitted: (DateTime value) { + selectedDate = value; + }, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder dateInput1 = find.descendant( + of: find.byType(TextField), + matching: find.text('2025..2..26'), + ); + expect(dateInput1, findsOneWidget); + + await tester.tap(dateInput1); + await tester.pumpAndSettle(); + + await tester.enterText(dateInput1, '2025..3..10'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(selectedDate, DateTime(2025, DateTime.march, 10)); + + final Finder dateInput2 = find.descendant( + of: find.byType(TextField), + matching: find.text('2025..3..10'), + ); + expect(dateInput2, findsOneWidget); + + await tester.tap(dateInput2); + await tester.pumpAndSettle(); + + await tester.enterText(dateInput2, '2025..4..21'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(selectedDate, DateTime(2025, DateTime.april, 21)); + }); + }); +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return '${date.year}..${date.month}..${date.day}'; + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + final List parts = inputString!.split('..'); + if (parts.length != 3) { + return null; + } + final int year = int.tryParse(parts[0]) ?? 0; + final int month = int.tryParse(parts[1]) ?? 0; + final int day = int.tryParse(parts[2]) ?? 0; + return DateTime(year, month, day); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return 'yyyy..mm..dd'; + } }