year_picker.dart 19 KB

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