From 2a8e35cc9c42e6957b069439eaa56207afed5ea2 Mon Sep 17 00:00:00 2001 From: Viet Do Date: Sat, 29 Sep 2018 08:29:28 +0700 Subject: [PATCH] Cupertino Date Picker (#21251) --- .../demo/cupertino/cupertino_picker_demo.dart | 206 +++- .../lib/src/cupertino/date_picker.dart | 907 +++++++++++++++++- .../lib/src/cupertino/localizations.dart | 67 +- .../test/cupertino/date_picker_test.dart | 390 +++++++- .../test/cupertino/localizations_test.dart | 3 + 5 files changed, 1474 insertions(+), 99 deletions(-) diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart index 5d1e3ff2485..33090616091 100644 --- a/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart +++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../../gallery/demo.dart'; import 'cupertino_navigation_demo.dart' show coolColorNames; @@ -22,6 +23,15 @@ class _CupertinoPickerDemoState extends State { Duration timer = Duration(); + // Value that is shown in the date picker in date mode. + DateTime date = DateTime.now(); + + // Value that is shown in the date picker in time mode. + DateTime time = DateTime.now(); + + // Value that is shown in the date picker in dateAndTime mode. + DateTime dateTime = DateTime.now(); + Widget _buildMenu(List children) { return Container( decoration: const BoxDecoration( @@ -53,30 +63,10 @@ class _CupertinoPickerDemoState extends State { ); } - Widget _buildColorPicker() { - final FixedExtentScrollController scrollController = - FixedExtentScrollController(initialItem: _selectedColorIndex); - return CupertinoPicker( - scrollController: scrollController, - itemExtent: _kPickerItemHeight, - backgroundColor: CupertinoColors.white, - onSelectedItemChanged: (int index) { - setState(() { - _selectedColorIndex = index; - }); - }, - children: List.generate(coolColorNames.length, (int index) { - return Center(child: - Text(coolColorNames[index]), - ); - }), - ); - } - Widget _buildBottomPicker(Widget picker) { return Container( height: _kPickerSheetHeight, - padding: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.only(top: 6.0), color: CupertinoColors.white, child: DefaultTextStyle( style: const TextStyle( @@ -95,6 +85,47 @@ class _CupertinoPickerDemoState extends State { ); } + Widget _buildColorPicker(BuildContext context) { + final FixedExtentScrollController scrollController = + FixedExtentScrollController(initialItem: _selectedColorIndex); + + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return _buildBottomPicker( + CupertinoPicker( + scrollController: scrollController, + itemExtent: _kPickerItemHeight, + backgroundColor: CupertinoColors.white, + onSelectedItemChanged: (int index) { + setState(() => _selectedColorIndex = index); + }, + children: List.generate(coolColorNames.length, (int index) { + return Center(child: + Text(coolColorNames[index]), + ); + }), + ), + ); + }, + ); + }, + child: _buildMenu( + [ + const Text('Favorite Color'), + Text( + coolColorNames[_selectedColorIndex], + style: const TextStyle( + color: CupertinoColors.inactiveGray + ), + ), + ], + ), + ); + } + Widget _buildCountdownTimerPicker(BuildContext context) { return GestureDetector( onTap: () { @@ -105,9 +136,7 @@ class _CupertinoPickerDemoState extends State { CupertinoTimerPicker( initialTimerDuration: timer, onTimerDurationChanged: (Duration newTimer) { - setState(() { - timer = newTimer; - }); + setState(() => timer = newTimer); }, ), ); @@ -115,15 +144,105 @@ class _CupertinoPickerDemoState extends State { ); }, child: _buildMenu( - [ - const Text('Countdown Timer'), - Text( - '${timer.inHours}:' + [ + const Text('Countdown Timer'), + Text( + '${timer.inHours}:' '${(timer.inMinutes % 60).toString().padLeft(2,'0')}:' '${(timer.inSeconds % 60).toString().padLeft(2,'0')}', - style: const TextStyle(color: CupertinoColors.inactiveGray), - ), - ] + style: const TextStyle(color: CupertinoColors.inactiveGray), + ), + ], + ), + ); + } + + Widget _buildDatePicker(BuildContext context) { + return GestureDetector( + onTap: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return _buildBottomPicker( + CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: date, + onDateTimeChanged: (DateTime newDateTime) { + setState(() => date = newDateTime); + }, + ), + ); + }, + ); + }, + child: _buildMenu( + [ + const Text('Date'), + Text( + DateFormat.yMMMMd().format(date), + style: const TextStyle(color: CupertinoColors.inactiveGray), + ), + ] + ), + ); + } + + Widget _buildTimePicker(BuildContext context) { + return GestureDetector( + onTap: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return _buildBottomPicker( + CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + initialDateTime: time, + onDateTimeChanged: (DateTime newDateTime) { + setState(() => time = newDateTime); + }, + ), + ); + }, + ); + }, + child: _buildMenu( + [ + const Text('Time'), + Text( + DateFormat.jm().format(time), + style: const TextStyle(color: CupertinoColors.inactiveGray), + ), + ], + ), + ); + } + + Widget _buildDateAndTimePicker(BuildContext context) { + return GestureDetector( + onTap: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return _buildBottomPicker( + CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + initialDateTime: dateTime, + onDateTimeChanged: (DateTime newDateTime) { + setState(() => dateTime = newDateTime); + }, + ), + ); + }, + ); + }, + child: _buildMenu( + [ + const Text('Date and Time'), + Text( + DateFormat.yMMMd().add_jm().format(dateTime), + style: const TextStyle(color: CupertinoColors.inactiveGray), + ), + ], ), ); } @@ -146,28 +265,11 @@ class _CupertinoPickerDemoState extends State { child: ListView( children: [ const Padding(padding: EdgeInsets.only(top: 32.0)), - GestureDetector( - onTap: () async { - await showCupertinoModalPopup( - context: context, - builder: (BuildContext context) { - return _buildBottomPicker(_buildColorPicker()); - }, - ); - }, - child: _buildMenu( - [ - const Text('Favorite Color'), - Text( - coolColorNames[_selectedColorIndex], - style: const TextStyle( - color: CupertinoColors.inactiveGray - ), - ), - ] - ), - ), + _buildColorPicker(context), _buildCountdownTimerPicker(context), + _buildDatePicker(context), + _buildTimePicker(context), + _buildDateAndTimePicker(context), ], ), ), diff --git a/packages/flutter/lib/src/cupertino/date_picker.dart b/packages/flutter/lib/src/cupertino/date_picker.dart index f7c3be1f2b4..6d687b3f02d 100644 --- a/packages/flutter/lib/src/cupertino/date_picker.dart +++ b/packages/flutter/lib/src/cupertino/date_picker.dart @@ -2,30 +2,906 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'localizations.dart'; import 'picker.dart'; -/// Default aesthetic values obtained by comparing with iOS pickers. +// Default aesthetic values obtained by comparing with iOS pickers. const double _kItemExtent = 32.0; const double _kPickerWidth = 330.0; -/// Considers setting the default background color from the theme, in the future. +const bool _kUseMagnifier = true; +const double _kMagnification = 1.1; +const double _kDatePickerPadSize = 12.0; +// Considers setting the default background color from the theme, in the future. const Color _kBackgroundColor = CupertinoColors.white; +// Lays out the date picker based on how much space each single column needs. +// +// Each column is a child of this delegate, indexed from 0 to number of columns - 1. +// Each column will be padded horizontally by 12.0 both left and right. +// +// The picker will be placed in the center, and the leftmost and rightmost +// column will be extended equally to the remaining width. +class _DatePickerLayoutDelegate extends MultiChildLayoutDelegate { + _DatePickerLayoutDelegate({ + @required this.columnWidths, + @required this.textDirectionFactor, + }) : assert(columnWidths != null), + assert(textDirectionFactor != null); -// The iOS timer picker has its width fixed to 330.0 in all modes. + // The list containing widths of all columns. + final List columnWidths; + + // textDirectionFactor is 1 if text is written left to right, and -1 if right to left. + final int textDirectionFactor; + + @override + void performLayout(Size size) { + double remainingWidth = size.width; + + for (int i = 0; i < columnWidths.length; i++) + remainingWidth -= columnWidths[i] + _kDatePickerPadSize * 2; + + double currentHorizontalOffset = 0.0; + + for (int i = 0; i < columnWidths.length; i++) { + final int index = textDirectionFactor == 1 ? i : columnWidths.length - i - 1; + + double childWidth = columnWidths[index] + _kDatePickerPadSize * 2; + if (index == 0 || index == columnWidths.length - 1) + childWidth += remainingWidth / 2; + + layoutChild(index, BoxConstraints.tight(Size(childWidth, size.height))); + positionChild(index, Offset(currentHorizontalOffset, 0.0)); + + currentHorizontalOffset += childWidth; + } + } + + @override + bool shouldRelayout(_DatePickerLayoutDelegate oldDelegate) { + return columnWidths != oldDelegate.columnWidths + || textDirectionFactor != oldDelegate.textDirectionFactor; + } +} + +/// Different display modes of [CupertinoDatePicker]. +/// +/// See also: +/// +/// * [CupertinoDatePicker], the class that implements different display modes +/// of the iOS-style date picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. +enum CupertinoDatePickerMode { + /// Mode that shows the date in hour, minute, and (optional) an AM/PM designation. + /// The AM/PM designation is shown only if [CupertinoDatePicker] does not use 24h format. + /// Column order is subject to internationalization. + /// + /// Example: [4 | 14 | PM]. + time, + /// Mode that shows the date in month, day of month, and year. + /// Name of month is spelled in full. + /// Column order is subject to internationalization. + /// + /// Example: [July | 13 | 2012]. + date, + /// Mode that shows the date as day of the week, month, day of month and + /// the time in hour, minute, and (optional) an AM/PM designation. + /// The AM/PM designation is shown only if [CupertinoDatePicker] does not use 24h format. + /// Column order is subject to internationalization. + /// + /// Example: [Fri Jul 13 | 4 | 14 | PM] + dateAndTime, +} + +// Different types of column in CupertinoDatePicker. +enum _PickerColumnType { + // Day of month column in date mode. + dayOfMonth, + // Month column in date mode. + month, + // Year column in date mode. + year, + // Medium date column in dateAndTime mode. + date, + // Hour column in time and dateAndTime mode. + hour, + // minute column in time and dateAndTime mode. + minute, + // AM/PM column in time and dateAndTime mode. + dayPeriod, +} + +/// A date picker widget in iOS style. +/// +/// There are several modes of the date picker listed in [CupertinoDatePickerMode]. +/// +/// The class will display its children as consecutive columns. Its children +/// order is based on internationalization. +/// +/// Example of the picker in date mode: +/// +/// * US-English: [July | 13 | 2012] +/// * Vietnamese: [13 | Tháng 7 | 2012] +/// +/// See also: +/// +/// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. +class CupertinoDatePicker extends StatefulWidget { + /// Constructs an iOS style date picker. + /// + /// [mode] is one of the mode listed in [CupertinoDatePickerMode] and defaults + /// to [CupertinoDatePickerMode.dateAndTime]. + /// + /// [onDateTimeChanged] is the callback called when the selected date or time + /// changes and must not be null. + /// + /// [initialDateTime] is the initial date time of the picker. Defaults to the + /// present date and time and must not be null. The present must conform to + /// the intervals set in [minimumDate], [maximumDate], [minimumYear], and + /// [maximumYear]. + /// + /// [minimumDate] is the minimum date that the picker can be scrolled to in + /// [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit. + /// + /// [maximumDate] is the maximum date that the picker can be scrolled to in + /// [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit. + /// + /// [minimumYear] is the minimum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Defaults to 1 and must not be null. + /// + /// [maximumYear] is the maximum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Null if there's no limit. + /// + /// [minuteInterval] is the granularity of the minute spinner. Must be a + /// positive integer factor of 60. + /// + /// [use24hFormat] decides whether 24 hour format is used. Defaults to false. + CupertinoDatePicker({ + this.mode = CupertinoDatePickerMode.dateAndTime, + @required this.onDateTimeChanged, + // ignore: always_require_non_null_named_parameters + DateTime initialDateTime, + this.minimumDate, + this.maximumDate, + this.minimumYear = 1, + this.maximumYear, + this.minuteInterval = 1, + this.use24hFormat = false, + }) : this.initialDateTime = initialDateTime ?? DateTime.now(), + assert(mode != null), + assert(onDateTimeChanged != null), + assert(initialDateTime != null), + assert( + mode != CupertinoDatePickerMode.dateAndTime || minimumDate == null || !initialDateTime.isBefore(minimumDate), + 'initial date is before minimum date', + ), + assert( + mode != CupertinoDatePickerMode.dateAndTime || maximumDate == null || !initialDateTime.isAfter(maximumDate), + 'initial date is after maximum date', + ), + assert(minimumYear != null), + assert( + mode != CupertinoDatePickerMode.date || (minimumYear >= 1 && initialDateTime.year >= minimumYear), + 'initial year is not greater than minimum year, or mininum year is not positive', + ), + assert( + mode != CupertinoDatePickerMode.date || maximumYear == null || initialDateTime.year <= maximumYear, + 'initial year is not smaller than maximum year', + ), + assert( + minuteInterval > 0 && 60 % minuteInterval == 0, + 'minute interval is not a positive integer factor of 60', + ), + assert( + initialDateTime.minute % minuteInterval == 0, + 'initial minute is not divisible by minute interval', + ); + + /// The mode of the date picker as one of [CupertinoDatePickerMode]. + /// Defaults to [CupertinoDatePickerMode.dateAndTime]. Cannot be null and + /// value cannot change after initial build. + final CupertinoDatePickerMode mode; + + /// The initial date and/or time of the picker. Defaults to the present date + /// and time and must not be null. The present must conform to the intervals + /// set in [minimumDate], [maximumDate], [minimumYear], and [maximumYear]. + /// + /// Changing this value after the initial build will not affect the currently + /// selected date time. + final DateTime initialDateTime; + + /// Minimum date that the picker can be scrolled to in + /// [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit. + final DateTime minimumDate; + + /// Maximum date that the picker can be scrolled to in + /// [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit. + final DateTime maximumDate; + + /// Minimum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Defaults to 1 and must not be null. + final int minimumYear; + + /// Maximum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Null if there's no limit. + final int maximumYear; + + /// The granularity of the minutes spinner, if it is shown in the current mode. + /// Must be an integer factor of 60. + final int minuteInterval; + + /// Whether to use 24 hour format. Defaults to false. + final bool use24hFormat; + + /// Callback called when the selected date and/or time changes. Must not be + /// null. + final ValueChanged onDateTimeChanged; + + @override + State createState() { + // The `time` mode and `dateAndTime` mode of the picker share the time + // columns, so they are placed together to one state. + // The `date` mode has different children and is implemented in a different + // state. + if (mode == CupertinoDatePickerMode.time || mode == CupertinoDatePickerMode.dateAndTime) + return _CupertinoDatePickerDateTimeState(); + else + return _CupertinoDatePickerDateState(); + } + + // Estimate the minimum width that each column needs to layout its content. + static double _getColumnWidth( + _PickerColumnType columnType, + CupertinoLocalizations localizations, + BuildContext context, + ) { + String longestText = ''; + + switch (columnType) { + case _PickerColumnType.date: + // Measuring the length of all possible date is impossible, so here + // just some dates are measured. + for (int i = 1; i <= 12; i++) { + // An arbitrary date. + final String date = + localizations.datePickerMediumDate(DateTime(2018, i, 25)); + if (longestText.length < date.length) + longestText = date; + } + break; + case _PickerColumnType.hour: + for (int i = 0 ; i < 24; i++) { + final String hour = localizations.datePickerHour(i); + if (longestText.length < hour.length) + longestText = hour; + } + break; + case _PickerColumnType.minute: + for (int i = 0 ; i < 60; i++) { + final String minute = localizations.datePickerMinute(i); + if (longestText.length < minute.length) + longestText = minute; + } + break; + case _PickerColumnType.dayPeriod: + longestText = + localizations.anteMeridiemAbbreviation.length > localizations.postMeridiemAbbreviation.length + ? localizations.anteMeridiemAbbreviation + : localizations.postMeridiemAbbreviation; + break; + case _PickerColumnType.dayOfMonth: + for (int i = 1 ; i <=31; i++) { + final String dayOfMonth = localizations.datePickerDayOfMonth(i); + if (longestText.length < dayOfMonth.length) + longestText = dayOfMonth; + } + break; + case _PickerColumnType.month: + for (int i = 1 ; i <=12; i++) { + final String month = localizations.datePickerMonth(i); + if (longestText.length < month.length) + longestText = month; + } + break; + case _PickerColumnType.year: + longestText = localizations.datePickerYear(2018); + break; + } + + assert(longestText != '', 'column type is not appropriate'); + + final TextPainter painter = TextPainter( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + text: longestText, + ), + textDirection: Directionality.of(context), + ); + + // This operation is expensive and should be avoided. It is called here only + // because there's no other way to get the information we want without + // laying out the text. + painter.layout(); + + return painter.maxIntrinsicWidth; + } +} + +typedef _ColumnBuilder = Widget Function(double offAxisFraction, TransitionBuilder itemPositioningBuilder); + +class _CupertinoDatePickerDateTimeState extends State { + int textDirectionFactor; + CupertinoLocalizations localizations; + + // Alignment based on text direction. The variable name is self descriptive, + // however, when text direction is rtl, alignment is reversed. + Alignment alignCenterLeft; + Alignment alignCenterRight; + + // Read this out when the state is initially created. Changes in initialDateTime + // in the widget after first build is ignored. + DateTime initialDateTime; + + // The currently selected values of the date picker. + int selectedDayFromInitial; // The difference in days between the initial date and the currently selected date. + int selectedHour; + int selectedMinute; + int selectedAmPm; // 0 means AM, 1 means PM. + + // The controller of the AM/PM column. + FixedExtentScrollController amPmController; + + // Estimated width of columns. + final Map estimatedColumnWidths = {}; + + @override + void initState() { + super.initState(); + initialDateTime = widget.initialDateTime; + selectedDayFromInitial = 0; + selectedHour = widget.initialDateTime.hour; + selectedMinute = widget.initialDateTime.minute; + selectedAmPm = 0; + + if (!widget.use24hFormat) { + selectedAmPm = selectedHour ~/ 12; + selectedHour = selectedHour % 12; + if (selectedHour == 0) + selectedHour = 12; + + amPmController = FixedExtentScrollController(initialItem: selectedAmPm); + } + } + + @override + void didUpdateWidget(CupertinoDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + + assert( + oldWidget.mode == widget.mode, + "The CupertinoDatePicker's mode cannot change once it's built", + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; + localizations = CupertinoLocalizations.of(context) ?? const DefaultCupertinoLocalizations(); + + alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; + alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; + + estimatedColumnWidths.clear(); + } + + // Lazily calculate the column width of the column being displayed only. + double _getEstimatedColumnWidth(_PickerColumnType columnType) { + if (estimatedColumnWidths[columnType.index] == null) { + estimatedColumnWidths[columnType.index] = + CupertinoDatePicker._getColumnWidth(columnType, localizations, context); + } + + return estimatedColumnWidths[columnType.index]; + } + + // Gets the current date time of the picker. + DateTime _getDateTime() { + final DateTime date = DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day, + ).add(Duration(days: selectedDayFromInitial)); + + return DateTime( + date.year, + date.month, + date.day, + selectedHour + selectedAmPm * 12, + selectedMinute, + ); + } + + // Builds the date column. The date is displayed in medium date format (e.g. Fri Aug 31). + Widget _buildMediumDatePicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + return CupertinoPicker.builder( + scrollController: FixedExtentScrollController(initialItem: selectedDayFromInitial), + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + selectedDayFromInitial = index; + widget.onDateTimeChanged(_getDateTime()); + }, + itemBuilder: (BuildContext context, int index) { + final DateTime dateTime = DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day, + ).add(Duration(days: index)); + + if (widget.minimumDate != null && dateTime.isBefore(widget.minimumDate)) + return null; + if (widget.maximumDate != null && dateTime.isAfter(widget.maximumDate)) + return null; + + return itemPositioningBuilder( + context, + Text(localizations.datePickerMediumDate(dateTime)), + ); + }, + ); + } + + Widget _buildHourPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + return CupertinoPicker( + scrollController: FixedExtentScrollController(initialItem: selectedHour), + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + if (widget.use24hFormat) { + selectedHour = index; + widget.onDateTimeChanged(_getDateTime()); + } + else { + final int currentHourIn24h = selectedHour + selectedAmPm * 12; + // Automatically scrolls the am/pm column when the hour column value + // goes far enough. This behavior is similar to + // iOS picker version. + if (currentHourIn24h ~/ 12 != index ~/ 12) { + selectedHour = index % 12; + amPmController.animateToItem( + 1 - amPmController.selectedItem, + duration: const Duration(milliseconds: 300), // Set by comparing with iOS version. + curve: Curves.easeOut, + ); // Set by comparing with iOS version. + } + else { + selectedHour = index % 12; + widget.onDateTimeChanged(_getDateTime()); + } + } + }, + children: List.generate(24, (int index) { + int hour = index; + if (!widget.use24hFormat) + hour = hour % 12 == 0 ? 12 : hour % 12; + + return itemPositioningBuilder( + context, + Text( + localizations.datePickerHour(hour), + semanticsLabel: localizations.datePickerHourSemanticsLabel(hour), + ), + ); + }), + looping: true, + ); + } + + Widget _buildMinutePicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + return CupertinoPicker( + scrollController: FixedExtentScrollController(initialItem: selectedMinute), + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + selectedMinute = index * widget.minuteInterval; + widget.onDateTimeChanged(_getDateTime()); + }, + children: List.generate(60 ~/ widget.minuteInterval, (int index) { + final int minute = index * widget.minuteInterval; + return itemPositioningBuilder( + context, + Text( + localizations.datePickerMinute(minute), + semanticsLabel: localizations.datePickerMinuteSemanticsLabel(minute), + ), + ); + }), + looping: true, + ); + } + + Widget _buildAmPmPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + return CupertinoPicker( + scrollController: amPmController, + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + selectedAmPm = index; + widget.onDateTimeChanged(_getDateTime()); + }, + children: List.generate(2, (int index) { + return itemPositioningBuilder( + context, + Text( + index == 0 + ? localizations.anteMeridiemAbbreviation + : localizations.postMeridiemAbbreviation + ), + ); + }), + ); + } + + @override + Widget build(BuildContext context) { + // Widths of the columns in this picker, ordered from left to right. + final List columnWidths = [ + _getEstimatedColumnWidth(_PickerColumnType.hour), + _getEstimatedColumnWidth(_PickerColumnType.minute), + ]; + final List<_ColumnBuilder> pickerBuilders = <_ColumnBuilder>[ + _buildHourPicker, + _buildMinutePicker, + ]; + + // Adds am/pm column if the picker is not using 24h format. + if (!widget.use24hFormat) { + if (localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.date_time_dayPeriod + || localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.time_dayPeriod_date) { + pickerBuilders.add(_buildAmPmPicker); + columnWidths.add(_getEstimatedColumnWidth(_PickerColumnType.dayPeriod)); + } + else { + pickerBuilders.insert(0, _buildAmPmPicker); + columnWidths.insert(0, _getEstimatedColumnWidth(_PickerColumnType.dayPeriod)); + } + } + + // Adds medium date column if the picker's mode is date and time. + if (widget.mode == CupertinoDatePickerMode.dateAndTime) { + if (localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.time_dayPeriod_date + || localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.dayPeriod_time_date) { + pickerBuilders.add(_buildMediumDatePicker); + columnWidths.add(_getEstimatedColumnWidth(_PickerColumnType.date)); + } + else { + pickerBuilders.insert(0, _buildMediumDatePicker); + columnWidths.insert(0, _getEstimatedColumnWidth(_PickerColumnType.date)); + } + } + + final List pickers = []; + + for (int i = 0; i < columnWidths.length; i++) { + double offAxisFraction = 0.0; + if (i == 0) + offAxisFraction = -0.5 * textDirectionFactor; + else if (i >= 2 || columnWidths.length == 2) + offAxisFraction = 0.5 * textDirectionFactor; + + EdgeInsets padding = const EdgeInsets.only(right: _kDatePickerPadSize); + if (i == columnWidths.length - 1) + padding = padding.flipped; + if (textDirectionFactor == -1) + padding = padding.flipped; + + pickers.add(LayoutId( + id: i, + child: pickerBuilders[i]( + offAxisFraction, + (BuildContext context, Widget child) { + return Container( + alignment: i == columnWidths.length - 1 + ? alignCenterLeft + : alignCenterRight, + padding: padding, + child: Container( + alignment: i == columnWidths.length - 1 ? alignCenterLeft : alignCenterRight, + width: i == 0 || i == columnWidths.length - 1 + ? null + : columnWidths[i] + _kDatePickerPadSize, + child: child, + ), + ); + }, + ), + )); + } + + return MediaQuery( + data: const MediaQueryData(textScaleFactor: 1.0), + child: CustomMultiChildLayout( + delegate: _DatePickerLayoutDelegate( + columnWidths: columnWidths, + textDirectionFactor: textDirectionFactor, + ), + children: pickers, + ), + ); + } +} + +class _CupertinoDatePickerDateState extends State { + int textDirectionFactor; + CupertinoLocalizations localizations; + + // Alignment based on text direction. The variable name is self descriptive, + // however, when text direction is rtl, alignment is reversed. + Alignment alignCenterLeft; + Alignment alignCenterRight; + + // The currently selected values of the picker. + int selectedDay; + int selectedMonth; + int selectedYear; + + // The controller of the day picker. There are cases where the selected value + // of the picker is invalid (e.g. February 30th 2018), and this dayController + // is responsible for jumping to a valid value. + FixedExtentScrollController dayController; + + // Estimated width of columns. + Map estimatedColumnWidths = {}; + + @override + void initState() { + super.initState(); + selectedDay = widget.initialDateTime.day; + selectedMonth = widget.initialDateTime.month; + selectedYear = widget.initialDateTime.year; + + dayController = FixedExtentScrollController(initialItem: selectedDay - 1); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; + localizations = CupertinoLocalizations.of(context) ?? const DefaultCupertinoLocalizations(); + + alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; + alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; + + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index] = CupertinoDatePicker._getColumnWidth(_PickerColumnType.dayOfMonth, localizations, context); + estimatedColumnWidths[_PickerColumnType.month.index] = CupertinoDatePicker._getColumnWidth(_PickerColumnType.month, localizations, context); + estimatedColumnWidths[_PickerColumnType.year.index] = CupertinoDatePicker._getColumnWidth(_PickerColumnType.year, localizations, context); + } + + Widget _buildDayPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + final int daysInCurrentMonth = DateTime(selectedYear, (selectedMonth + 1) % 12, 0).day; + return CupertinoPicker( + scrollController: dayController, + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + selectedDay = index + 1; + if (DateTime(selectedYear, selectedMonth, selectedDay).day == selectedDay) + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay)); + }, + children: List.generate(31, (int index) { + TextStyle disableTextStyle; // Null if not out of range. + if (index >= daysInCurrentMonth) { + disableTextStyle = const TextStyle(color: CupertinoColors.inactiveGray); + } + return itemPositioningBuilder( + context, + Text( + localizations.datePickerDayOfMonth(index + 1), + style: disableTextStyle, + ), + ); + }), + looping: true, + ); + } + + Widget _buildMonthPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + return CupertinoPicker( + scrollController: FixedExtentScrollController(initialItem: selectedMonth - 1), + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + selectedMonth = index + 1; + if (DateTime(selectedYear, selectedMonth, selectedDay).day == selectedDay) + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay)); + }, + children: List.generate(12, (int index) { + return itemPositioningBuilder( + context, + Text(localizations.datePickerMonth(index + 1)), + ); + }), + looping: true, + ); + } + + Widget _buildYearPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { + return CupertinoPicker.builder( + scrollController: FixedExtentScrollController(initialItem: selectedYear), + itemExtent: _kItemExtent, + offAxisFraction: offAxisFraction, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: _kBackgroundColor, + onSelectedItemChanged: (int index) { + selectedYear = index; + if (DateTime(selectedYear, selectedMonth, selectedDay).day == selectedDay) + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay)); + }, + itemBuilder: (BuildContext context, int index) { + if (index < widget.minimumYear) + return null; + + if (widget.maximumYear != null && index > widget.maximumYear) + return null; + + return itemPositioningBuilder( + context, + Text(localizations.datePickerYear(index)), + ); + }, + ); + } + + bool _keepInValidRange(ScrollEndNotification notification) { + // Whenever scrolling lands on an invalid entry, the picker + // automatically scrolls to a valid one. + final int desiredDay = DateTime(selectedYear, selectedMonth, selectedDay).day; + if (desiredDay != selectedDay) { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + dayController.animateToItem( + // The next valid date is also the amount of days overflown. + dayController.selectedItem - desiredDay, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); + } + setState(() { + // Rebuild because the number of valid days per month are different + // depending on the month and year. + }); + return false; + } + + @override + Widget build(BuildContext context) { + List<_ColumnBuilder> pickerBuilders = <_ColumnBuilder>[]; + List columnWidths = []; + + switch (localizations.datePickerDateOrder) { + case DatePickerDateOrder.mdy: + pickerBuilders = <_ColumnBuilder>[_buildMonthPicker, _buildDayPicker, _buildYearPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.month.index], + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index], + estimatedColumnWidths[_PickerColumnType.year.index]]; + break; + case DatePickerDateOrder.dmy: + pickerBuilders = <_ColumnBuilder>[_buildDayPicker, _buildMonthPicker, _buildYearPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index], + estimatedColumnWidths[_PickerColumnType.month.index], + estimatedColumnWidths[_PickerColumnType.year.index]]; + break; + case DatePickerDateOrder.ymd: + pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildMonthPicker, _buildDayPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.year.index], + estimatedColumnWidths[_PickerColumnType.month.index], + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]]; + break; + case DatePickerDateOrder.ydm: + pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildDayPicker, _buildMonthPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.year.index], + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index], + estimatedColumnWidths[_PickerColumnType.month.index]]; + break; + default: + assert(false, 'date order is not specified'); + } + + final List pickers = []; + + for (int i = 0; i < columnWidths.length; i++) { + final double offAxisFraction = (i - 1) * 0.3 * textDirectionFactor; + + EdgeInsets padding = const EdgeInsets.only(right: _kDatePickerPadSize); + if (textDirectionFactor == -1) + padding = const EdgeInsets.only(left: _kDatePickerPadSize); + + pickers.add(LayoutId( + id: i, + child: pickerBuilders[i]( + offAxisFraction, + (BuildContext context, Widget child) { + return Container( + alignment: i == columnWidths.length - 1 + ? alignCenterLeft + : alignCenterRight, + padding: i == 0 ? null : padding, + child: Container( + alignment: i == 0 ? alignCenterLeft : alignCenterRight, + width: columnWidths[i] + _kDatePickerPadSize, + child: child, + ), + ); + }, + ), + )); + } + + return MediaQuery( + data: const MediaQueryData(textScaleFactor: 1.0), + child: NotificationListener( + onNotification: _keepInValidRange, + child: CustomMultiChildLayout( + delegate: _DatePickerLayoutDelegate( + columnWidths: columnWidths, + textDirectionFactor: textDirectionFactor, + ), + children: pickers, + ), + ), + ); + } +} + + +// The iOS date picker and timer picker has their width fixed to 330.0 in all +// modes. // // If the maximum width given to the picker is greater than 330.0, the leftmost // and rightmost column will be extended equally so that the widths match, and // the picker is in the center. // -// If the maximum width given to the picker is smaller than 330.0, the picker is -// placed in the center and both left side and right side are clipped. +// If the maximum width given to the picker is smaller than 330.0, the picker's +// layout will be broken. /// Different modes of [CupertinoTimerPicker]. +/// +/// See also: +/// +/// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. enum CupertinoTimerPickerMode { /// Mode that shows the timer duration in hour and minute. /// @@ -47,14 +923,20 @@ enum CupertinoTimerPickerMode { /// The duration is bound between 0 and 23 hours 59 minutes 59 seconds. /// /// There are several modes of the timer picker listed in [CupertinoTimerPickerMode]. +/// +/// See also: +/// +/// * [CupertinoDatePicker], the class that implements different display modes +/// of the iOS-style date picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. class CupertinoTimerPicker extends StatefulWidget { /// Constructs an iOS style countdown timer picker. /// /// [mode] is one of the modes listed in [CupertinoTimerPickerMode] and /// defaults to [CupertinoTimerPickerMode.hms]. /// - /// [onTimerDurationChanged] is the callback when the selected duration changes - /// and must not be null. + /// [onTimerDurationChanged] is the callback called when the selected duration + /// changes and must not be null. /// /// [initialTimerDuration] defaults to 0 second and is limited from 0 second /// to 23 hours 59 minutes 59 seconds. @@ -93,7 +975,7 @@ class CupertinoTimerPicker extends StatefulWidget { /// of 60. final int secondInterval; - /// Callback when the timer duration changes. + /// Callback called when the timer duration changes. final ValueChanged onTimerDurationChanged; @override @@ -304,8 +1186,7 @@ class _CupertinoTimerPickerState extends State { ), ), ); - } - else { + } else { minuteLabel = IgnorePointer( child: Container( alignment: alignCenterRight, @@ -420,16 +1301,14 @@ class _CupertinoTimerPickerState extends State { Expanded(child: _buildMinuteColumn()), ], ); - } - else if (widget.mode == CupertinoTimerPickerMode.ms) { + } else if (widget.mode == CupertinoTimerPickerMode.ms) { picker = Row( children: [ Expanded(child: _buildMinuteColumn()), Expanded(child: _buildSecondColumn()), ], ); - } - else { + } else { picker = Row( children: [ Expanded(child: _buildHourColumn()), diff --git a/packages/flutter/lib/src/cupertino/localizations.dart b/packages/flutter/lib/src/cupertino/localizations.dart index 92317be246e..bf6c4a5a5e6 100644 --- a/packages/flutter/lib/src/cupertino/localizations.dart +++ b/packages/flutter/lib/src/cupertino/localizations.dart @@ -7,6 +7,46 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +/// Determines the order of the columns inside [CupertinoDatePicker] in +/// time and date time mode. +enum DatePickerDateTimeOrder { + /// Order of the columns, from left to right: date, hour, minute, am/pm. + /// + /// Example: [Fri Aug 31 | 02 | 08 | PM]. + date_time_dayPeriod, + /// Order of the columns, from left to right: date, am/pm, hour, minute. + /// + /// Example: [Fri Aug 31 | PM | 02 | 08]. + date_dayPeriod_time, + /// Order of the columns, from left to right: hour, minute, am/pm, date. + /// + /// Example: [02 | 08 | PM | Fri Aug 31]. + time_dayPeriod_date, + /// Order of the columns, from left to right: am/pm, hour, minute, date. + /// + /// Example: [PM | 02 | 08 | Fri Aug 31]. + dayPeriod_time_date, +} + +/// Determines the order of the columns inside [CupertinoDatePicker] in date mode. +enum DatePickerDateOrder { + /// Order of the columns, from left to right: day, month, year. + /// + /// Example: [12 | March | 1996] + dmy, + /// Order of the columns, from left to right: month, day, year. + /// + /// Example: [March | 12 | 1996] + mdy, + /// Order of the columns, from left to right: year, month, day. + /// + /// Example: [1996 | March | 12] + ymd, + /// Order of the columns, from left to right: year, day, month. + /// + /// Example: [1996 | 12 | March] + ydm, +} /// Defines the localized resource values used by the Cupertino widgets. /// @@ -61,6 +101,9 @@ abstract class CupertinoLocalizations { /// - Arabic: ٠١ String datePickerHour(int hour); + /// Semantics label for the given hour value in [CupertinoDatePicker]. + String datePickerHourSemanticsLabel(int hour); + /// Minute that is shown in [CupertinoDatePicker] spinner corresponding /// to the given minute value. /// @@ -70,9 +113,14 @@ abstract class CupertinoLocalizations { /// - Arabic: ٠١ String datePickerMinute(int minute); + /// Semantics label for the given minute value in [CupertinoDatePicker]. + String datePickerMinuteSemanticsLabel(int minute); + /// The order of the date elements that will be shown in [CupertinoDatePicker]. - /// Can be any permutation of 'DMY' ('D': day, 'M': month, 'Y': year). - String get datePickerDateOrder; + DatePickerDateOrder get datePickerDateOrder; + + /// The order of the time elements that will be shown in [CupertinoDatePicker]. + DatePickerDateTimeOrder get datePickerDateTimeOrder; /// The abbreviation for ante meridiem (before noon) shown in the time picker. String get anteMeridiemAbbreviation; @@ -216,9 +264,19 @@ class DefaultCupertinoLocalizations implements CupertinoLocalizations { @override String datePickerHour(int hour) => hour.toString().padLeft(2, '0'); + @override + String datePickerHourSemanticsLabel(int hour) => hour.toString() + " o'clock"; + @override String datePickerMinute(int minute) => minute.toString().padLeft(2, '0'); + @override + String datePickerMinuteSemanticsLabel(int minute) { + if (minute == 1) + return '1 minute'; + return minute.toString() + ' minutes'; + } + @override String datePickerMediumDate(DateTime date) { return '${_shortWeekdays[date.weekday - DateTime.monday]} ' @@ -227,7 +285,10 @@ class DefaultCupertinoLocalizations implements CupertinoLocalizations { } @override - String get datePickerDateOrder => 'MDY'; + DatePickerDateOrder get datePickerDateOrder => DatePickerDateOrder.mdy; + + @override + DatePickerDateTimeOrder get datePickerDateTimeOrder => DatePickerDateTimeOrder.date_time_dayPeriod; @override String get anteMeridiemAbbreviation => 'AM'; diff --git a/packages/flutter/test/cupertino/date_picker_test.dart b/packages/flutter/test/cupertino/date_picker_test.dart index adc09e3985a..2282d260eb8 100644 --- a/packages/flutter/test/cupertino/date_picker_test.dart +++ b/packages/flutter/test/cupertino/date_picker_test.dart @@ -97,36 +97,6 @@ void main() { ); }); - testWidgets('secondInterval is positive and is a factor of 60', (WidgetTester tester) async { - expect( - () { - CupertinoTimerPicker( - onTimerDurationChanged: (_) {}, - secondInterval: 0, - ); - }, - throwsAssertionError, - ); - expect( - () { - CupertinoTimerPicker( - onTimerDurationChanged: (_) {}, - secondInterval: -1, - ); - }, - throwsAssertionError, - ); - expect( - () { - CupertinoTimerPicker( - onTimerDurationChanged: (_) {}, - secondInterval: 7, - ); - }, - throwsAssertionError, - ); - }); - testWidgets('columns are ordered correctly when text direction is ltr', (WidgetTester tester) async { await tester.pumpWidget( Directionality( @@ -223,4 +193,364 @@ void main() { ); }); }); + group('Date picker', () { + testWidgets('mode is not null', (WidgetTester tester) async { + expect( + () { + CupertinoDatePicker( + mode: null, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime.now(), + ); + }, + throwsAssertionError, + ); + }); + + testWidgets('onDateTimeChanged is not null', (WidgetTester tester) async { + expect( + () { + CupertinoDatePicker( + onDateTimeChanged: null, + initialDateTime: DateTime.now(), + ); + }, + throwsAssertionError, + ); + }); + + testWidgets('initial date time is not null', (WidgetTester tester) async { + expect( + () { + CupertinoDatePicker( + onDateTimeChanged: (_) {}, + initialDateTime: null, + ); + }, + throwsAssertionError, + ); + }); + + testWidgets('initial date time is not null', (WidgetTester tester) async { + expect( + () { + CupertinoDatePicker( + onDateTimeChanged: (_) {}, + initialDateTime: null, + ); + }, + throwsAssertionError, + ); + }); + + testWidgets('changing initialDateTime after first build does not do anything', (WidgetTester tester) async { + DateTime selectedDateTime; + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + onDateTimeChanged: (DateTime dateTime) => selectedDateTime = dateTime, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + await tester.drag(find.text('10'), const Offset(0.0, 32.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(selectedDateTime, DateTime(2018, 1, 1, 9, 30)); + + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + onDateTimeChanged: (DateTime dateTime) => selectedDateTime = dateTime, + // Change the initial date, but it shouldn't affect the present state. + initialDateTime: DateTime(2016, 4, 5, 15, 00), + ), + ), + ), + ); + + await tester.drag(find.text('09'), const Offset(0.0, 32.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Moving up an hour is still based on the original initial date time. + expect(selectedDateTime, DateTime(2018, 1, 1, 8, 30)); + }); + + testWidgets('width of picker in date and time mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('Mon Jan 1')).dx - tester.getCenter(find.text('AM')).dx; + + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 800.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('Mon Jan 1')).dx - tester.getCenter(find.text('AM')).dx, + distance, + ); + }); + + testWidgets('width of picker in date mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('January')).dx - tester.getCenter(find.text('2018')).dx; + + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 800.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('January')).dx - tester.getCenter(find.text('2018')).dx, + distance, + ); + }); + + testWidgets('width of picker in time mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('10')).dx - tester.getCenter(find.text('AM')).dx; + + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 800.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('10')).dx - tester.getCenter(find.text('AM')).dx, + distance, + ); + }); + + testWidgets('picker automatically scrolls away from invalid date on month change', (WidgetTester tester) async { + DateTime date; + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2018, 3, 30), + ), + ), + ), + ); + + await tester.drag(find.text('March'), const Offset(0.0, 32.0)); + // Momentarily, the 2018 and the incorrect 30 of February is aligned. + expect( + tester.getTopLeft(find.text('2018')).dy, + tester.getTopLeft(find.text('30')).dy, + ); + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pump(); // Once to start the DrivenScrollActivity. + await tester.pump(const Duration(milliseconds: 500)); + + expect( + date, + DateTime(2018, 2, 28), + ); + expect( + tester.getTopLeft(find.text('2018')).dy, + tester.getTopLeft(find.text('28')).dy, + ); + }); + + testWidgets('picker automatically scrolls away from invalid date on day change', (WidgetTester tester) async { + DateTime date; + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2018, 2, 27), // 2018 has 28 days in Feb. + ), + ), + ), + ); + + await tester.drag(find.text('27'), const Offset(0.0, -32.0)); + await tester.pump(); + expect( + date, + DateTime(2018, 2, 28), + ); + + + await tester.drag(find.text('28'), const Offset(0.0, -32.0)); + await tester.pump(); // Once to trigger the post frame animate call. + + // Callback doesn't transiently go into invalid dates. + expect( + date, + DateTime(2018, 2, 28), + ); + // Momentarily, the invalid 29th of Feb is dragged into the middle. + expect( + tester.getTopLeft(find.text('2018')).dy, + tester.getTopLeft(find.text('29')).dy, + ); + + await tester.pump(); // Once to start the DrivenScrollActivity. + await tester.pump(const Duration(milliseconds: 500)); + + expect( + date, + DateTime(2018, 2, 28), + ); + expect( + tester.getTopLeft(find.text('2018')).dy, + tester.getTopLeft(find.text('28')).dy, + ); + }); + + testWidgets('picker automatically scrolls the am/pm column when the hour column changes enough', (WidgetTester tester) async { + DateTime date; + await tester.pumpWidget( + SizedBox( + height: 400.0, + width: 400.0, + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2018, 1, 1, 11, 59), + ), + ), + ), + ); + + await tester.drag(find.text('11'), const Offset(0.0, -32.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 12, 59)); + + await tester.drag(find.text('12'), const Offset(0.0, 32.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 11, 59)); + + await tester.drag(find.text('11'), const Offset(0.0, 64.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 9, 59)); + + await tester.drag(find.text('09'), const Offset(0.0, -192.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 15, 59)); + }); + }); } \ No newline at end of file diff --git a/packages/flutter/test/cupertino/localizations_test.dart b/packages/flutter/test/cupertino/localizations_test.dart index b8be8b60710..cc23c25616f 100644 --- a/packages/flutter/test/cupertino/localizations_test.dart +++ b/packages/flutter/test/cupertino/localizations_test.dart @@ -13,9 +13,12 @@ void main() { expect(localizations.datePickerMonth(1), isNotNull); expect(localizations.datePickerDayOfMonth(1), isNotNull); expect(localizations.datePickerHour(0), isNotNull); + expect(localizations.datePickerHourSemanticsLabel(0), isNotNull); expect(localizations.datePickerMinute(0), isNotNull); + expect(localizations.datePickerMinuteSemanticsLabel(0), isNotNull); expect(localizations.datePickerMediumDate(DateTime.now()), isNotNull); expect(localizations.datePickerDateOrder, isNotNull); + expect(localizations.datePickerDateTimeOrder, isNotNull); expect(localizations.anteMeridiemAbbreviation, isNotNull); expect(localizations.postMeridiemAbbreviation, isNotNull);