month_picker.dart 19 KB

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