Line data Source code
1 : import 'dart:async';
2 :
3 : import 'package:flutter/material.dart';
4 : import 'package:flutter/rendering.dart';
5 : import 'package:flutter_localizations/flutter_localizations.dart';
6 : import 'package:intl/intl.dart' as intl;
7 :
8 : import 'date_picker_keys.dart';
9 : import 'day_type.dart';
10 : import 'i_selectable_picker.dart';
11 : import 'month_picker_selection.dart';
12 : import 'semantic_sorting.dart';
13 : import 'styles/date_picker_styles.dart';
14 : import 'styles/layout_settings.dart';
15 : import 'utils.dart';
16 :
17 : const Locale _defaultLocale = Locale('en', 'US');
18 :
19 : /// Month picker widget.
20 : class MonthPicker<T extends Object> extends StatefulWidget {
21 0 : MonthPicker._({
22 : Key? key,
23 : required this.selectionLogic,
24 : required this.selection,
25 : required this.onChanged,
26 : required this.firstDate,
27 : required this.lastDate,
28 : this.datePickerLayoutSettings = const DatePickerLayoutSettings(),
29 : this.datePickerKeys,
30 : required this.datePickerStyles,
31 0 : }) : assert(!firstDate.isAfter(lastDate)),
32 : assert(
33 0 : selection.isEmpty || !selection.isBefore(firstDate),
34 : 'Selection must not be before first date. '
35 0 : 'Earliest selection is: ${selection.earliest}. '
36 : 'First date is: $firstDate'),
37 : assert(
38 0 : selection.isEmpty || !selection.isAfter(lastDate),
39 : 'Selection must not be after last date. '
40 0 : 'Latest selection is: ${selection.latest}. '
41 : 'First date is: $lastDate'),
42 0 : super(key: key);
43 :
44 : /// Creates a month picker where only one single month can be selected.
45 : ///
46 : /// See also:
47 : /// * [MonthPicker.multi] - month picker where many single months
48 : /// can be selected.
49 0 : static MonthPicker<DateTime> single(
50 : {Key? key,
51 : required DateTime selectedDate,
52 : required ValueChanged<DateTime> onChanged,
53 : required DateTime firstDate,
54 : required DateTime lastDate,
55 : DatePickerLayoutSettings datePickerLayoutSettings =
56 : const DatePickerLayoutSettings(),
57 : DatePickerStyles? datePickerStyles,
58 : DatePickerKeys? datePickerKeys,
59 : SelectableDayPredicate? selectableDayPredicate,
60 : ValueChanged<DateTime>? onMonthChanged}) {
61 0 : assert(!firstDate.isAfter(lastDate));
62 0 : assert(!lastDate.isBefore(firstDate));
63 0 : assert(!selectedDate.isBefore(firstDate));
64 0 : assert(!selectedDate.isAfter(lastDate));
65 :
66 0 : final selection = MonthPickerSingleSelection(selectedDate);
67 0 : final selectionLogic = MonthSelectable(selectedDate, firstDate, lastDate,
68 : selectableDayPredicate: selectableDayPredicate);
69 :
70 0 : return MonthPicker<DateTime>._(
71 : onChanged: onChanged,
72 : firstDate: firstDate,
73 : lastDate: lastDate,
74 : selectionLogic: selectionLogic,
75 : selection: selection,
76 : datePickerKeys: datePickerKeys,
77 0 : datePickerStyles: datePickerStyles ?? DatePickerRangeStyles(),
78 : datePickerLayoutSettings: datePickerLayoutSettings,
79 : );
80 : }
81 :
82 : /// Creates a month picker where many single months can be selected.
83 : ///
84 : /// See also:
85 : /// * [MonthPicker.single] - month picker where only one single month
86 : /// can be selected.
87 0 : static MonthPicker<List<DateTime>> multi(
88 : {Key? key,
89 : required List<DateTime> selectedDates,
90 : required ValueChanged<List<DateTime>> onChanged,
91 : required DateTime firstDate,
92 : required DateTime lastDate,
93 : DatePickerLayoutSettings datePickerLayoutSettings =
94 : const DatePickerLayoutSettings(),
95 : DatePickerStyles? datePickerStyles,
96 : DatePickerKeys? datePickerKeys,
97 : SelectableDayPredicate? selectableDayPredicate,
98 : ValueChanged<DateTime>? onMonthChanged}) {
99 0 : assert(!firstDate.isAfter(lastDate));
100 0 : assert(!lastDate.isBefore(firstDate));
101 :
102 0 : final selection = MonthPickerMultiSelection(selectedDates);
103 0 : final selectionLogic = MonthMultiSelectable(
104 : selectedDates, firstDate, lastDate,
105 : selectableDayPredicate: selectableDayPredicate);
106 :
107 0 : return MonthPicker<List<DateTime>>._(
108 : onChanged: onChanged,
109 : firstDate: firstDate,
110 : lastDate: lastDate,
111 : selectionLogic: selectionLogic,
112 : selection: selection,
113 : datePickerKeys: datePickerKeys,
114 0 : datePickerStyles: datePickerStyles ?? DatePickerStyles(),
115 : datePickerLayoutSettings: datePickerLayoutSettings,
116 : );
117 : }
118 :
119 : /// The currently selected date or dates.
120 : ///
121 : /// This date or dates are highlighted in the picker.
122 : final MonthPickerSelection selection;
123 :
124 : /// Called when the user picks a month.
125 : final ValueChanged<T> onChanged;
126 :
127 : /// The earliest date the user is permitted to pick.
128 : final DateTime firstDate;
129 :
130 : /// The latest date the user is permitted to pick.
131 : final DateTime lastDate;
132 :
133 : /// Layout settings what can be customized by user
134 : final DatePickerLayoutSettings datePickerLayoutSettings;
135 :
136 : /// Some keys useful for integration tests
137 : final DatePickerKeys? datePickerKeys;
138 :
139 : /// Styles what can be customized by user
140 : final DatePickerStyles datePickerStyles;
141 :
142 : /// Logic to handle user's selections.
143 : final ISelectablePicker<T> selectionLogic;
144 :
145 0 : @override
146 0 : State<StatefulWidget> createState() => _MonthPickerState<T>();
147 : }
148 :
149 : class _MonthPickerState<T extends Object> extends State<MonthPicker<T>> {
150 : PageController _monthPickerController = PageController();
151 :
152 : Locale locale = _defaultLocale;
153 : MaterialLocalizations localizations = _defaultLocalizations;
154 :
155 : TextDirection textDirection = TextDirection.ltr;
156 :
157 : DateTime _todayDate = DateTime.now();
158 : DateTime _previousYearDate = DateTime(DateTime.now().year - 1);
159 : DateTime _nextYearDate = DateTime(DateTime.now().year + 1);
160 :
161 : DateTime _currentDisplayedYearDate = DateTime.now();
162 :
163 : Timer? _timer;
164 : StreamSubscription<T>? _changesSubscription;
165 :
166 : /// True if the earliest allowable year is displayed.
167 0 : bool get _isDisplayingFirstYear =>
168 0 : !_currentDisplayedYearDate.isAfter(DateTime(widget.firstDate.year));
169 :
170 : /// True if the latest allowable year is displayed.
171 0 : bool get _isDisplayingLastYear =>
172 0 : !_currentDisplayedYearDate.isBefore(DateTime(widget.lastDate.year));
173 :
174 0 : @override
175 : void initState() {
176 0 : super.initState();
177 0 : _initWidgetData();
178 0 : _updateCurrentDate();
179 : }
180 :
181 0 : @override
182 : void didUpdateWidget(MonthPicker<T> oldWidget) {
183 0 : super.didUpdateWidget(oldWidget);
184 0 : if (widget.selection != oldWidget.selection ||
185 0 : widget.selectionLogic != oldWidget.selectionLogic) {
186 0 : _initWidgetData();
187 : }
188 : }
189 :
190 0 : @override
191 : void didChangeDependencies() {
192 0 : super.didChangeDependencies();
193 :
194 : try {
195 0 : locale = Localizations.localeOf(context);
196 :
197 : MaterialLocalizations? curLocalizations =
198 0 : Localizations.of<MaterialLocalizations>(
199 0 : context, MaterialLocalizations);
200 0 : if (curLocalizations != null && localizations != curLocalizations) {
201 0 : localizations = curLocalizations;
202 : }
203 :
204 0 : textDirection = Directionality.of(context);
205 :
206 : // No MaterialLocalizations or Directionality or Locale was found
207 : // and ".of" method throws error
208 : // trying to cast null to MaterialLocalizations.
209 0 : } on TypeError catch (_) {}
210 : }
211 :
212 0 : @override
213 : Widget build(BuildContext context) {
214 : int yearsCount =
215 0 : DatePickerUtils.yearDelta(widget.firstDate, widget.lastDate) + 1;
216 :
217 0 : return SizedBox(
218 0 : width: widget.datePickerLayoutSettings.monthPickerPortraitWidth,
219 0 : height: widget.datePickerLayoutSettings.maxDayPickerHeight,
220 0 : child: Stack(
221 0 : children: <Widget>[
222 0 : Semantics(
223 : sortKey: YearPickerSortKey.calendar,
224 0 : child: PageView.builder(
225 : // key: ValueKey<DateTime>(widget.selection),
226 0 : controller: _monthPickerController,
227 : scrollDirection: Axis.horizontal,
228 : itemCount: yearsCount,
229 0 : itemBuilder: _buildItems,
230 0 : onPageChanged: _handleYearPageChanged,
231 : ),
232 : ),
233 0 : PositionedDirectional(
234 : top: 0.0,
235 : start: 8.0,
236 0 : child: Semantics(
237 : sortKey: YearPickerSortKey.previousYear,
238 0 : child: IconButton(
239 0 : key: widget.datePickerKeys?.previousPageIconKey,
240 0 : icon: widget.datePickerStyles.prevIcon,
241 0 : tooltip: _isDisplayingFirstYear
242 : ? null
243 0 : : '${localizations.formatYear(_previousYearDate)}',
244 0 : onPressed: _isDisplayingFirstYear ? null : _handlePreviousYear,
245 : ),
246 : ),
247 : ),
248 0 : PositionedDirectional(
249 : top: 0.0,
250 : end: 8.0,
251 0 : child: Semantics(
252 : sortKey: YearPickerSortKey.nextYear,
253 0 : child: IconButton(
254 0 : key: widget.datePickerKeys?.nextPageIconKey,
255 0 : icon: widget.datePickerStyles.nextIcon,
256 0 : tooltip: _isDisplayingLastYear
257 : ? null
258 0 : : '${localizations.formatYear(_nextYearDate)}',
259 0 : onPressed: _isDisplayingLastYear ? null : _handleNextYear,
260 : ),
261 : ),
262 : ),
263 : ],
264 : ),
265 : );
266 : }
267 :
268 0 : @override
269 : void dispose() {
270 0 : _timer?.cancel();
271 0 : _changesSubscription?.cancel();
272 0 : super.dispose();
273 : }
274 :
275 0 : void _initWidgetData() {
276 : final initiallyShowDate =
277 0 : widget.selection.isEmpty ? DateTime.now() : widget.selection.earliest;
278 :
279 : // Initially display the pre-selected date.
280 : final int yearPage =
281 0 : DatePickerUtils.yearDelta(widget.firstDate, initiallyShowDate);
282 :
283 0 : _changesSubscription?.cancel();
284 0 : _changesSubscription = widget.selectionLogic.onUpdate
285 0 : .listen((newSelectedDate) => widget.onChanged(newSelectedDate))
286 0 : ..onError((e) => print(e.toString()));
287 :
288 0 : _monthPickerController.dispose();
289 0 : _monthPickerController = PageController(initialPage: yearPage);
290 0 : _handleYearPageChanged(yearPage);
291 : }
292 :
293 0 : void _updateCurrentDate() {
294 0 : _todayDate = DateTime.now();
295 : final DateTime tomorrow =
296 0 : DateTime(_todayDate.year, _todayDate.month, _todayDate.day + 1);
297 0 : Duration timeUntilTomorrow = tomorrow.difference(_todayDate);
298 0 : timeUntilTomorrow +=
299 : const Duration(seconds: 1); // so we don't miss it by rounding
300 0 : _timer?.cancel();
301 0 : _timer = Timer(timeUntilTomorrow, () {
302 0 : setState(_updateCurrentDate);
303 : });
304 : }
305 :
306 : /// Add years to a year truncated date.
307 0 : DateTime _addYearsToYearDate(DateTime yearDate, int yearsToAdd) =>
308 0 : DateTime(yearDate.year + yearsToAdd);
309 :
310 0 : Widget _buildItems(BuildContext context, int index) {
311 0 : final DateTime year = _addYearsToYearDate(widget.firstDate, index);
312 :
313 0 : final ThemeData theme = Theme.of(context);
314 0 : DatePickerStyles styles = widget.datePickerStyles;
315 0 : styles = styles.fulfillWithTheme(theme);
316 :
317 0 : return _MonthPicker<T>(
318 0 : key: ValueKey<DateTime>(year),
319 0 : currentDate: _todayDate,
320 0 : onChanged: widget.onChanged,
321 0 : firstDate: widget.firstDate,
322 0 : lastDate: widget.lastDate,
323 0 : datePickerLayoutSettings: widget.datePickerLayoutSettings,
324 : displayedYear: year,
325 0 : selectedPeriodKey: widget.datePickerKeys?.selectedPeriodKeys,
326 : datePickerStyles: styles,
327 0 : locale: locale,
328 0 : localizations: localizations,
329 0 : selectionLogic: widget.selectionLogic,
330 : );
331 : }
332 :
333 0 : void _handleNextYear() {
334 0 : if (!_isDisplayingLastYear) {
335 0 : String yearStr = localizations.formatYear(_nextYearDate);
336 0 : SemanticsService.announce(yearStr, textDirection);
337 0 : _monthPickerController.nextPage(
338 0 : duration: widget.datePickerLayoutSettings.pagesScrollDuration,
339 : curve: Curves.ease);
340 : }
341 : }
342 :
343 0 : void _handlePreviousYear() {
344 0 : if (!_isDisplayingFirstYear) {
345 0 : String yearStr = localizations.formatYear(_previousYearDate);
346 0 : SemanticsService.announce(yearStr, textDirection);
347 0 : _monthPickerController.previousPage(
348 0 : duration: widget.datePickerLayoutSettings.pagesScrollDuration,
349 : curve: Curves.ease);
350 : }
351 : }
352 :
353 0 : void _handleYearPageChanged(int yearPage) {
354 0 : setState(() {
355 0 : _previousYearDate = _addYearsToYearDate(widget.firstDate, yearPage - 1);
356 0 : _currentDisplayedYearDate =
357 0 : _addYearsToYearDate(widget.firstDate, yearPage);
358 0 : _nextYearDate = _addYearsToYearDate(widget.firstDate, yearPage + 1);
359 : });
360 : }
361 :
362 0 : static MaterialLocalizations get _defaultLocalizations =>
363 0 : MaterialLocalizationEn(
364 : twoDigitZeroPaddedFormat:
365 0 : intl.NumberFormat('00', _defaultLocale.toString()),
366 0 : fullYearFormat: intl.DateFormat.y(_defaultLocale.toString()),
367 0 : longDateFormat: intl.DateFormat.yMMMMEEEEd(_defaultLocale.toString()),
368 0 : shortMonthDayFormat: intl.DateFormat.MMMd(_defaultLocale.toString()),
369 : decimalFormat:
370 0 : intl.NumberFormat.decimalPattern(_defaultLocale.toString()),
371 0 : shortDateFormat: intl.DateFormat.yMMMd(_defaultLocale.toString()),
372 0 : mediumDateFormat: intl.DateFormat.MMMEd(_defaultLocale.toString()),
373 0 : compactDateFormat: intl.DateFormat.yMd(_defaultLocale.toString()),
374 0 : yearMonthFormat: intl.DateFormat.yMMMM(_defaultLocale.toString()),
375 : );
376 : }
377 :
378 : class _MonthPicker<T> extends StatelessWidget {
379 : /// The month whose days are displayed by this picker.
380 : final DateTime displayedYear;
381 :
382 : /// The earliest date the user is permitted to pick.
383 : final DateTime firstDate;
384 :
385 : /// The latest date the user is permitted to pick.
386 : final DateTime lastDate;
387 :
388 : /// The current date at the time the picker is displayed.
389 : final DateTime currentDate;
390 :
391 : /// Layout settings what can be customized by user
392 : final DatePickerLayoutSettings datePickerLayoutSettings;
393 :
394 : /// Called when the user picks a day.
395 : final ValueChanged<T> onChanged;
396 :
397 : /// Key fo selected month (useful for integration tests)
398 : final Key? selectedPeriodKey;
399 :
400 : /// Styles what can be customized by user
401 : final DatePickerStyles datePickerStyles;
402 :
403 : final MaterialLocalizations localizations;
404 :
405 : final ISelectablePicker<T> selectionLogic;
406 :
407 : final Locale locale;
408 :
409 0 : _MonthPicker(
410 : {required this.displayedYear,
411 : required this.firstDate,
412 : required this.lastDate,
413 : required this.currentDate,
414 : required this.onChanged,
415 : required this.datePickerLayoutSettings,
416 : required this.datePickerStyles,
417 : required this.selectionLogic,
418 : required this.localizations,
419 : required this.locale,
420 : this.selectedPeriodKey,
421 : Key? key})
422 0 : : assert(!firstDate.isAfter(lastDate)),
423 0 : super(key: key);
424 :
425 0 : @override
426 : Widget build(BuildContext context) {
427 : final int monthsInYear = 12;
428 0 : final int year = displayedYear.year;
429 : final int day = 1;
430 :
431 0 : final List<Widget> labels = <Widget>[];
432 :
433 0 : for (int month = 1; month <= monthsInYear; month += 1) {
434 0 : DateTime monthToBuild = DateTime(year, month, day);
435 0 : DayType monthType = selectionLogic.getDayType(monthToBuild);
436 :
437 0 : Widget monthWidget = _MonthCell(
438 : monthToBuild: monthToBuild,
439 0 : currentDate: currentDate,
440 0 : selectionLogic: selectionLogic,
441 0 : datePickerStyles: datePickerStyles,
442 0 : localizations: localizations,
443 0 : locale: locale,
444 : );
445 :
446 0 : if (monthType != DayType.disabled) {
447 0 : monthWidget = GestureDetector(
448 : behavior: HitTestBehavior.opaque,
449 0 : onTap: () {
450 0 : DatePickerUtils.sameMonth(firstDate, monthToBuild)
451 0 : ? selectionLogic.onDayTapped(firstDate)
452 0 : : selectionLogic.onDayTapped(monthToBuild);
453 : },
454 : child: monthWidget,
455 : );
456 : }
457 :
458 0 : labels.add(monthWidget);
459 : }
460 :
461 0 : return Padding(
462 : padding: const EdgeInsets.symmetric(horizontal: 8.0),
463 0 : child: Column(
464 0 : children: <Widget>[
465 0 : Container(
466 0 : height: datePickerLayoutSettings.dayPickerRowHeight,
467 0 : child: Center(
468 0 : child: ExcludeSemantics(
469 0 : child: Text(
470 0 : localizations.formatYear(displayedYear),
471 0 : key: selectedPeriodKey,
472 0 : style: datePickerStyles.displayedPeriodTitle,
473 : ),
474 : ),
475 : ),
476 : ),
477 0 : Flexible(
478 0 : child: GridView.count(
479 0 : physics: datePickerLayoutSettings.scrollPhysics,
480 : crossAxisCount: 4,
481 : children: labels,
482 : ),
483 : ),
484 : ],
485 : ),
486 : );
487 : }
488 : }
489 :
490 : class _MonthCell<T> extends StatelessWidget {
491 : /// Styles what can be customized by user
492 : final DatePickerStyles datePickerStyles;
493 : final Locale locale;
494 : final MaterialLocalizations localizations;
495 : final ISelectablePicker<T> selectionLogic;
496 : final DateTime monthToBuild;
497 :
498 : /// The current date at the time the picker is displayed.
499 : final DateTime currentDate;
500 :
501 0 : const _MonthCell({
502 : required this.monthToBuild,
503 : required this.currentDate,
504 : required this.selectionLogic,
505 : required this.datePickerStyles,
506 : required this.locale,
507 : required this.localizations,
508 : Key? key,
509 0 : }) : super(key: key);
510 :
511 0 : @override
512 : Widget build(BuildContext context) {
513 0 : DayType monthType = selectionLogic.getDayType(monthToBuild);
514 :
515 : BoxDecoration? decoration;
516 : TextStyle? itemStyle;
517 :
518 0 : if (monthType != DayType.disabled && monthType != DayType.notSelected) {
519 0 : itemStyle = datePickerStyles.selectedDateStyle;
520 0 : decoration = datePickerStyles.selectedSingleDateDecoration;
521 0 : } else if (monthType == DayType.disabled) {
522 0 : itemStyle = datePickerStyles.disabledDateStyle;
523 0 : } else if (DatePickerUtils.sameMonth(currentDate, monthToBuild)) {
524 0 : itemStyle = datePickerStyles.currentDateStyle;
525 : } else {
526 0 : itemStyle = datePickerStyles.defaultDateTextStyle;
527 : }
528 :
529 0 : String semanticLabel =
530 0 : '${localizations.formatDecimal(monthToBuild.month)}, '
531 0 : '${localizations.formatFullDate(monthToBuild)}';
532 :
533 : bool isSelectedMonth =
534 0 : monthType != DayType.disabled && monthType != DayType.notSelected;
535 :
536 0 : String monthStr = _getMonthStr(monthToBuild);
537 :
538 0 : Widget monthWidget = Container(
539 : decoration: decoration,
540 0 : child: Center(
541 0 : child: Semantics(
542 : // We want the day of month to be spoken first irrespective of the
543 : // locale-specific preferences or TextDirection. This is because
544 : // an accessibility user is more likely to be interested in the
545 : // day of month before the rest of the date, as they are looking
546 : // for the day of month. To do that we prepend day of month to the
547 : // formatted full date.
548 : label: semanticLabel,
549 : selected: isSelectedMonth,
550 0 : child: ExcludeSemantics(
551 0 : child: Text(monthStr, style: itemStyle),
552 : ),
553 : ),
554 : ),
555 : );
556 :
557 : return monthWidget;
558 : }
559 :
560 : // Returns only month made with intl.DateFormat.MMM() for current [locale].
561 : // We can'r use [localizations] here because MaterialLocalizations doesn't
562 : // provide short month string.
563 0 : String _getMonthStr(DateTime date) {
564 0 : String month = intl.DateFormat.MMM(locale.toString()).format(date);
565 : return month;
566 : }
567 : }
|