season_picker.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  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. /// Season picker widget.
  16. class UtilSeasonPicker<T extends Object> extends StatefulWidget {
  17. UtilSeasonPicker({
  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. /// * [UtilSeasonPicker.multi] - month picker where many single months
  43. /// can be selected.
  44. static UtilSeasonPicker<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 UtilSeasonPicker<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. /// * [UtilSeasonPicker.single] - month picker where only one single month
  78. /// can be selected.
  79. static UtilSeasonPicker<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 UtilSeasonPicker<List<DateTime>>(
  98. onChanged: onChanged,
  99. firstDate: firstDate,
  100. lastDate: lastDate,
  101. selectionLogic: selectionLogic,
  102. selection: selection,
  103. datePickerKeys: datePickerKeys,
  104. datePickerStyles: 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<UtilSeasonPicker<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(UtilSeasonPicker<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. tooltip: _isDisplayingFirstYear
  207. ? null
  208. : '${localizations.formatYear(_previousYearDate!)}',
  209. onPressed: _isDisplayingFirstYear ? null : _handlePreviousYear,
  210. ),
  211. ),
  212. ),
  213. PositionedDirectional(
  214. top: 0.0,
  215. end: 8.0,
  216. child: Semantics(
  217. sortKey: YearPickerSortKey.nextYear,
  218. child: IconButton(
  219. key: widget.datePickerKeys?.nextPageIconKey,
  220. icon: widget.datePickerStyles!.nextIcon!,
  221. tooltip: _isDisplayingLastYear
  222. ? null
  223. : '${localizations.formatYear(_nextYearDate!)}',
  224. onPressed: _isDisplayingLastYear ? null : _handleNextYear,
  225. ),
  226. ),
  227. ),
  228. ],
  229. ),
  230. );
  231. }
  232. @override
  233. void dispose() {
  234. _timer?.cancel();
  235. _changesSubscription?.cancel();
  236. super.dispose();
  237. }
  238. void _initWidgetData() {
  239. final initiallyShowDate =
  240. widget.selection!.isEmpty ? DateTime.now() : widget.selection!.earliest;
  241. // Initially display the pre-selected date.
  242. final int yearPage =
  243. DatePickerUtils.yearDelta(widget.firstDate!, initiallyShowDate);
  244. _changesSubscription?.cancel();
  245. _changesSubscription = widget.selectionLogic!.onUpdate!
  246. .listen((newSelectedDate) => widget.onChanged!(newSelectedDate))
  247. ..onError((e) => print(e.toString()));
  248. _monthPickerController.dispose();
  249. _monthPickerController = PageController(initialPage: yearPage);
  250. _handleYearPageChanged(yearPage);
  251. }
  252. void _updateCurrentDate() {
  253. _todayDate = DateTime.now();
  254. final DateTime tomorrow =
  255. DateTime(_todayDate!.year, _todayDate!.month, _todayDate!.day + 1);
  256. Duration timeUntilTomorrow = tomorrow.difference(_todayDate!);
  257. timeUntilTomorrow +=
  258. const Duration(seconds: 1); // so we don't miss it by rounding
  259. _timer?.cancel();
  260. _timer = Timer(timeUntilTomorrow, () {
  261. setState(_updateCurrentDate);
  262. });
  263. }
  264. /// Add years to a year truncated date.
  265. DateTime _addYearsToYearDate(DateTime yearDate, int yearsToAdd) =>
  266. DateTime(yearDate.year + yearsToAdd);
  267. Widget _buildItems(BuildContext context, int index) {
  268. final DateTime year = _addYearsToYearDate(widget.firstDate!, index);
  269. final ThemeData theme = Theme.of(context);
  270. DatePickerStyles styles = widget.datePickerStyles!;
  271. styles = styles.fulfillWithTheme(theme);
  272. return _WMonthPicker<T>(
  273. key: ValueKey<DateTime>(year),
  274. currentDate: _todayDate!,
  275. onChanged: widget.onChanged!,
  276. firstDate: widget.firstDate!,
  277. lastDate: widget.lastDate!,
  278. datePickerLayoutSettings: widget.datePickerLayoutSettings!,
  279. displayedYear: year,
  280. selectedPeriodKey: widget.datePickerKeys?.selectedPeriodKeys,
  281. datePickerStyles: styles,
  282. locale: locale,
  283. localizations: localizations,
  284. selectionLogic: widget.selectionLogic!,
  285. );
  286. }
  287. void _handleNextYear() {
  288. if (!_isDisplayingLastYear) {
  289. String yearStr = localizations.formatYear(_nextYearDate!);
  290. SemanticsService.announce(yearStr, textDirection);
  291. _monthPickerController.nextPage(
  292. duration: widget.datePickerLayoutSettings!.pagesScrollDuration,
  293. curve: Curves.ease);
  294. }
  295. }
  296. void _handlePreviousYear() {
  297. if (!_isDisplayingFirstYear) {
  298. String yearStr = localizations.formatYear(_previousYearDate!);
  299. SemanticsService.announce(yearStr, textDirection);
  300. _monthPickerController.previousPage(
  301. duration: widget.datePickerLayoutSettings!.pagesScrollDuration,
  302. curve: Curves.ease);
  303. }
  304. }
  305. void _handleYearPageChanged(int yearPage) {
  306. setState(() {
  307. _previousYearDate = _addYearsToYearDate(widget.firstDate!, yearPage - 1);
  308. _currentDisplayedYearDate =
  309. _addYearsToYearDate(widget.firstDate!, yearPage);
  310. _nextYearDate = _addYearsToYearDate(widget.firstDate!, yearPage + 1);
  311. });
  312. }
  313. static MaterialLocalizations get _defaultLocalizations =>
  314. MaterialLocalizationEn(
  315. twoDigitZeroPaddedFormat:
  316. intl.NumberFormat('00', _defaultLocale.toString()),
  317. fullYearFormat: intl.DateFormat.y(_defaultLocale.toString()),
  318. longDateFormat: intl.DateFormat.yMMMMEEEEd(_defaultLocale.toString()),
  319. shortMonthDayFormat: intl.DateFormat.MMMd(_defaultLocale.toString()),
  320. decimalFormat:
  321. intl.NumberFormat.decimalPattern(_defaultLocale.toString()),
  322. shortDateFormat: intl.DateFormat.yMMMd(_defaultLocale.toString()),
  323. mediumDateFormat: intl.DateFormat.MMMEd(_defaultLocale.toString()),
  324. compactDateFormat: intl.DateFormat.yMd(_defaultLocale.toString()),
  325. yearMonthFormat: intl.DateFormat.yMMMM(_defaultLocale.toString()),
  326. );
  327. }
  328. class _WMonthPicker<T> extends StatelessWidget {
  329. /// The month whose days are displayed by this picker.
  330. final DateTime? displayedYear;
  331. /// The earliest date the user is permitted to pick.
  332. final DateTime? firstDate;
  333. /// The latest date the user is permitted to pick.
  334. final DateTime? lastDate;
  335. /// The current date at the time the picker is displayed.
  336. final DateTime? currentDate;
  337. /// Layout settings what can be customized by user
  338. final DatePickerLayoutSettings? datePickerLayoutSettings;
  339. /// Called when the user picks a day.
  340. final ValueChanged<T>? onChanged;
  341. /// Key fo selected month (useful for integration tests)
  342. final Key? selectedPeriodKey;
  343. /// Styles what can be customized by user
  344. final DatePickerStyles? datePickerStyles;
  345. final MaterialLocalizations? localizations;
  346. final ISelectablePicker<T>? selectionLogic;
  347. final Locale? locale;
  348. _WMonthPicker(
  349. {required this.displayedYear,
  350. required this.firstDate,
  351. required this.lastDate,
  352. required this.currentDate,
  353. required this.onChanged,
  354. required this.datePickerLayoutSettings,
  355. required this.datePickerStyles,
  356. required this.selectionLogic,
  357. required this.localizations,
  358. required this.locale,
  359. this.selectedPeriodKey,
  360. Key? key})
  361. : assert(!firstDate!.isAfter(lastDate!)),
  362. super(key: key);
  363. @override
  364. Widget build(BuildContext context) {
  365. final int year = displayedYear!.year;
  366. final int day = 1;
  367. final List<Widget> labels = <Widget>[];
  368. for (int season = 1; season <= 12; season += 3) {
  369. DateTime seasonToBuild = DateTime(year, season, day);
  370. DayType seasonType = selectionLogic!.getDayType(seasonToBuild)!;
  371. Widget seasonWidget = _SeasonCell(
  372. seasonToBuild: seasonToBuild,
  373. currentDate: currentDate!,
  374. selectionLogic: selectionLogic!,
  375. datePickerStyles: datePickerStyles!,
  376. localizations: localizations!,
  377. locale: locale!,
  378. );
  379. if (seasonType != DayType.disabled) {
  380. seasonWidget = GestureDetector(
  381. behavior: HitTestBehavior.opaque,
  382. onTap: () {
  383. DatePickerUtils.sameMonth(firstDate!, seasonToBuild)
  384. ? selectionLogic!.onDayTapped(firstDate!)
  385. : selectionLogic!.onDayTapped(seasonToBuild);
  386. },
  387. child: seasonWidget,
  388. );
  389. }
  390. labels.add(seasonWidget);
  391. }
  392. return Padding(
  393. padding: const EdgeInsets.symmetric(horizontal: 8.0),
  394. child: Column(
  395. children: <Widget>[
  396. /// 顶部“年” 构建
  397. Container(
  398. height: datePickerLayoutSettings!.dayPickerRowHeight,
  399. child: Center(
  400. child: ExcludeSemantics(
  401. child: Text(
  402. localizations!.formatYear(displayedYear!),
  403. key: selectedPeriodKey,
  404. style: datePickerStyles!.displayedPeriodTitle,
  405. ),
  406. ),
  407. ),
  408. ),
  409. /// 季度表格构建
  410. Flexible(
  411. child: GridView.count(
  412. physics: datePickerLayoutSettings!.scrollPhysics,
  413. crossAxisCount: 4,
  414. children: labels,
  415. ),
  416. ),
  417. ],
  418. ),
  419. );
  420. }
  421. }
  422. class _SeasonCell<T> extends StatelessWidget {
  423. /// Styles what can be customized by user
  424. final DatePickerStyles datePickerStyles;
  425. final Locale locale;
  426. final MaterialLocalizations localizations;
  427. final ISelectablePicker<T> selectionLogic;
  428. final DateTime seasonToBuild;
  429. /// The current date at the time the picker is displayed.
  430. final DateTime currentDate;
  431. const _SeasonCell({
  432. required this.seasonToBuild,
  433. required this.currentDate,
  434. required this.selectionLogic,
  435. required this.datePickerStyles,
  436. required this.locale,
  437. required this.localizations,
  438. Key? key,
  439. }) : super(key: key);
  440. @override
  441. Widget build(BuildContext context) {
  442. DayType? seasonType = selectionLogic.getDayType(seasonToBuild)!;
  443. BoxDecoration? decoration;
  444. TextStyle? itemStyle;
  445. if (seasonType != DayType.disabled && seasonType != DayType.notSelected) {
  446. itemStyle = datePickerStyles.selectedDateStyle;
  447. decoration = datePickerStyles.selectedSingleDateDecoration;
  448. } else if (seasonType == DayType.disabled) {
  449. itemStyle = datePickerStyles.disabledDateStyle;
  450. } else if (DatePickerUtils.sameMonth(currentDate, seasonToBuild)) {
  451. itemStyle = datePickerStyles.currentDateStyle;
  452. } else {
  453. itemStyle = datePickerStyles.defaultDateTextStyle;
  454. }
  455. String semanticLabel =
  456. '${localizations.formatDecimal(seasonToBuild.month)}, '
  457. '${localizations.formatFullDate(seasonToBuild)}';
  458. bool isSelectedMonth =
  459. seasonType != DayType.disabled && seasonType != DayType.notSelected;
  460. String monthStr = _getSeasonStr(seasonToBuild);
  461. Widget seasonWidget = Container(
  462. decoration: decoration!,
  463. child: Center(
  464. child: Semantics(
  465. // We want the day of month to be spoken first irrespective of the
  466. // locale-specific preferences or TextDirection. This is because
  467. // an accessibility user is more likely to be interested in the
  468. // day of month before the rest of the date, as they are looking
  469. // for the day of month. To do that we prepend day of month to the
  470. // formatted full date.
  471. label: semanticLabel,
  472. selected: isSelectedMonth,
  473. child: ExcludeSemantics(
  474. child: Text(monthStr, style: itemStyle),
  475. ),
  476. ),
  477. ),
  478. );
  479. return seasonWidget;
  480. }
  481. // Returns only month made with intl.DateFormat.MMM() for current [locale].
  482. // We can'r use [localizations] here because MaterialLocalizations doesn't
  483. // provide short month string.
  484. String _getSeasonStr(DateTime date) {
  485. String season = intl.DateFormat.QQQ(locale.toString()).format(date);
  486. return season;
  487. }
  488. }