blob: 848edca71ea0b8ce5d90e1d9241c6cb7f64f7936 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'localizations.dart';
import 'picker.dart';
import 'theme.dart';
// Values derived from https://developer.apple.com/design/resources/ and on iOS
// simulators with "Debug View Hierarchy".
const double _kItemExtent = 32.0;
// From the picker's intrinsic content size constraint.
const double _kPickerWidth = 320.0;
const double _kPickerHeight = 216.0;
const bool _kUseMagnifier = true;
const double _kMagnification = 2.35/2.1;
const double _kDatePickerPadSize = 12.0;
// The density of a date picker is different from a generic picker.
// Eyeballed from iOS.
const double _kSqueeze = 1.25;
// Considers setting the default background color from the theme, in the future.
const Color _kBackgroundColor = CupertinoColors.white;
const TextStyle _kDefaultPickerTextStyle = TextStyle(
letterSpacing: -0.83,
);
// Half of the horizontal padding value between the timer picker's columns.
const double _kTimerPickerHalfColumnPadding = 2;
// The horizontal padding between the timer picker's number label and its
// corresponding unit label.
const double _kTimerPickerLabelPadSize = 4.5;
const double _kTimerPickerLabelFontSize = 17.0;
// The width of each colmn of the countdown time picker.
const double _kTimerPickerColumnIntrinsicWidth = 106;
// Unfortunately turning on magnification for the timer picker messes up the label
// alignment. So we'll have to hard code the font size and turn magnification off
// for now.
const double _kTimerPickerNumberLabelFontSize = 23;
TextStyle _themeTextStyle(BuildContext context, { bool isValid = true }) {
final TextStyle style = CupertinoTheme.of(context).textTheme.dateTimePickerTextStyle;
return isValid ? style : style.copyWith(color: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context));
}
// 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 list containing widths of all columns.
final List<double> 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;
// We can't actually assert here because it would break things badly for
// semantics, which will expect that we laid things out here.
assert(() {
if (childWidth < 0) {
FlutterError.reportError(
FlutterErrorDetails(
exception: FlutterError(
'Insufficient horizontal space to render the '
'CupertinoDatePicker because the parent is too narrow at '
'${size.width}px.\n'
'An additional ${-remainingWidth}px is needed to avoid '
'overlapping columns.',
),
),
);
}
return true;
}());
layoutChild(index, BoxConstraints.tight(Size(math.max(0.0, 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 `
///
/// Can be used with [showCupertinoModalPopup] to display the picker modally at
/// the bottom of the screen.
///
/// Sizes itself to its parent and may not render correctly if not given the
/// full screen width. Content texts are shown with
/// [CupertinoTextThemeData.dateTimePickerTextStyle].
///
/// 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.date] and [CupertinoDatePickerMode.dateAndTime]
/// mode. Null if there's no limit.
///
/// [maximumDate] is the maximum date that the picker can be scrolled to in
/// [CupertinoDatePickerMode.date] and [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,
DateTime initialDateTime,
this.minimumDate,
this.maximumDate,
this.minimumYear = 1,
this.maximumYear,
this.minuteInterval = 1,
this.use24hFormat = false,
this.backgroundColor = _kBackgroundColor,
}) : initialDateTime = initialDateTime ?? DateTime.now(),
assert(mode != null),
assert(onDateTimeChanged != null),
assert(minimumYear != null),
assert(backgroundColor != null),
assert(
minuteInterval > 0 && 60 % minuteInterval == 0,
'minute interval is not a positive integer factor of 60',
) {
assert(this.initialDateTime != null);
assert(
mode != CupertinoDatePickerMode.dateAndTime || minimumDate == null || !this.initialDateTime.isBefore(minimumDate),
'initial date is before minimum date',
);
assert(
mode != CupertinoDatePickerMode.dateAndTime || maximumDate == null || !this.initialDateTime.isAfter(maximumDate),
'initial date is after maximum date',
);
assert(
mode != CupertinoDatePickerMode.date || (minimumYear >= 1 && this.initialDateTime.year >= minimumYear),
'initial year is not greater than minimum year, or mininum year is not positive',
);
assert(
mode != CupertinoDatePickerMode.date || maximumYear == null || this.initialDateTime.year <= maximumYear,
'initial year is not smaller than maximum year',
);
assert(
mode != CupertinoDatePickerMode.date || minimumDate == null || !minimumDate.isAfter(this.initialDateTime),
'initial date ${this.initialDateTime} is not greater than or euqal to minimumDate $minimumDate',
);
assert(
mode != CupertinoDatePickerMode.date || maximumDate == null || !maximumDate.isBefore(this.initialDateTime),
'initial date ${this.initialDateTime} is not less than or euqal to maximumDate $maximumDate',
);
assert(
this.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.date]
/// and [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit.
final DateTime minimumDate;
/// Maximum date that the picker can be scrolled to in [CupertinoDatePickerMode.date]
/// and [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<DateTime> onDateTimeChanged;
/// Background color of date picker.
///
/// Defaults to [CupertinoColors.white] when null.
final Color backgroundColor;
@override
State<StatefulWidget> 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.
switch (mode) {
case CupertinoDatePickerMode.time:
case CupertinoDatePickerMode.dateAndTime:
return _CupertinoDatePickerDateTimeState();
case CupertinoDatePickerMode.date:
return _CupertinoDatePickerDateState();
}
assert(false);
return _CupertinoDatePickerDateTimeState();
}
// 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: _themeTextStyle(context),
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<CupertinoDatePicker> {
// Fraction of the farthest column's vanishing point vs its width. Eyeballed
// vs iOS.
static const double _kMaximumOffAxisFraction = 0.45;
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 difference in days between the initial date and the currently selected date.
int selectedDayFromInitial;
// The current selection of the hour picker.
//
// If [widget.use24hFormat] is true, values range from 1-24. Otherwise values
// range from 1-12.
int selectedHour;
// The previous selection index of the hour column.
//
// This ranges from 0-23 even if [widget.use24hFormat] is false. As a result,
// it can be used for determining if we just changed from AM -> PM or vice
// versa.
int previousHourIndex;
// The current selection of the minute picker. Values range from 0 to 59.
int selectedMinute;
// The current selection of the AM/PM picker.
//
// - 0 means AM
// - 1 means PM
int selectedAmPm;
// The controller of the AM/PM column.
FixedExtentScrollController amPmController;
// The estimated width of columns.
final Map<int, double> estimatedColumnWidths = <int, double>{};
@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);
}
previousHourIndex = selectedHour;
PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange);
}
void _handleSystemFontsChange () {
setState(() {
// System fonts change might cause the text layout width to change.
// Clears cached width to ensure that they get recalculated with the
// new system fonts.
estimatedColumnWidths.clear();
});
}
@override
void dispose() {
PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange);
super.dispose();
}
@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);
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,
widget.use24hFormat ? selectedHour : selectedHour % 12 + 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: widget.backgroundColor,
squeeze: _kSqueeze,
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;
final DateTime now = DateTime.now();
String dateText;
if (dateTime == DateTime(now.year, now.month, now.day)) {
dateText = localizations.todayLabel;
} else {
dateText = localizations.datePickerMediumDate(dateTime);
}
return itemPositioningBuilder(
context,
Text(
dateText,
style: _themeTextStyle(context),
),
);
},
);
}
Widget _buildHourPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) {
return CupertinoPicker(
scrollController: FixedExtentScrollController(initialItem: selectedHour),
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
onSelectedItemChanged: (int index) {
if (widget.use24hFormat) {
selectedHour = index;
widget.onDateTimeChanged(_getDateTime());
} else {
selectedHour = index % 12;
// Automatically scrolls the am/pm column when the hour column value
// goes far enough.
final bool wasAm = previousHourIndex >=0 && previousHourIndex <= 11;
final bool isAm = index >= 0 && index <= 11;
if (wasAm != isAm) {
// Animation values obtained by comparing with iOS version.
amPmController.animateToItem(
1 - amPmController.selectedItem,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} else {
widget.onDateTimeChanged(_getDateTime());
}
}
previousHourIndex = index;
},
children: List<Widget>.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),
style: _themeTextStyle(context),
),
);
}),
looping: true,
);
}
Widget _buildMinutePicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) {
return CupertinoPicker(
scrollController: FixedExtentScrollController(initialItem: selectedMinute ~/ widget.minuteInterval),
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
onSelectedItemChanged: (int index) {
selectedMinute = index * widget.minuteInterval;
widget.onDateTimeChanged(_getDateTime());
},
children: List<Widget>.generate(60 ~/ widget.minuteInterval, (int index) {
final int minute = index * widget.minuteInterval;
return itemPositioningBuilder(
context,
Text(
localizations.datePickerMinute(minute),
semanticsLabel: localizations.datePickerMinuteSemanticsLabel(minute),
style: _themeTextStyle(context),
),
);
}),
looping: true,
);
}
Widget _buildAmPmPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) {
return CupertinoPicker(
scrollController: amPmController,
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
onSelectedItemChanged: (int index) {
selectedAmPm = index;
widget.onDateTimeChanged(_getDateTime());
},
children: List<Widget>.generate(2, (int index) {
return itemPositioningBuilder(
context,
Text(
index == 0
? localizations.anteMeridiemAbbreviation
: localizations.postMeridiemAbbreviation,
style: _themeTextStyle(context),
),
);
}),
);
}
@override
Widget build(BuildContext context) {
// Widths of the columns in this picker, ordered from left to right.
final List<double> columnWidths = <double>[
_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<Widget> pickers = <Widget>[];
for (int i = 0; i < columnWidths.length; i++) {
double offAxisFraction = 0.0;
if (i == 0)
offAxisFraction = -_kMaximumOffAxisFraction * textDirectionFactor;
else if (i >= 2 || columnWidths.length == 2)
offAxisFraction = _kMaximumOffAxisFraction * 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: DefaultTextStyle.merge(
style: _kDefaultPickerTextStyle,
child: CustomMultiChildLayout(
delegate: _DatePickerLayoutDelegate(
columnWidths: columnWidths,
textDirectionFactor: textDirectionFactor,
),
children: pickers,
),
),
);
}
}
class _CupertinoDatePickerDateState extends State<CupertinoDatePicker> {
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;
FixedExtentScrollController monthController;
FixedExtentScrollController yearController;
bool isDayPickerScrolling = false;
bool isMonthPickerScrolling = false;
bool isYearPickerScrolling = false;
bool get isScrolling => isDayPickerScrolling || isMonthPickerScrolling || isYearPickerScrolling;
// Estimated width of columns.
Map<int, double> estimatedColumnWidths = <int, double>{};
@override
void initState() {
super.initState();
selectedDay = widget.initialDateTime.day;
selectedMonth = widget.initialDateTime.month;
selectedYear = widget.initialDateTime.year;
dayController = FixedExtentScrollController(initialItem: selectedDay - 1);
monthController = FixedExtentScrollController(initialItem: selectedMonth - 1);
yearController = FixedExtentScrollController(initialItem: selectedYear);
PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange);
}
void _handleSystemFontsChange() {
setState(() {
// System fonts change might cause the text layout width to change.
_refreshEstimatedColumnWidths();
});
}
@override
void dispose() {
dayController.dispose();
monthController.dispose();
yearController.dispose();
PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange);
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1;
localizations = CupertinoLocalizations.of(context);
alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight;
alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft;
_refreshEstimatedColumnWidths();
}
void _refreshEstimatedColumnWidths() {
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);
}
// The DateTime of the last day of a given month in a given year.
// Let `DateTime` handle the year/month overflow.
DateTime _lastDayInMonth(int year, int month) => DateTime(year, month + 1, 0);
Widget _buildDayPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) {
final int daysInCurrentMonth = _lastDayInMonth(selectedYear, selectedMonth).day;
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollStartNotification) {
isDayPickerScrolling = true;
} else if (notification is ScrollEndNotification) {
isDayPickerScrolling = false;
_pickerDidStopScrolling();
}
return false;
},
child: CupertinoPicker(
scrollController: dayController,
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
onSelectedItemChanged: (int index) {
selectedDay = index + 1;
if (_isCurrentDateValid)
widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay));
},
children: List<Widget>.generate(31, (int index) {
final int day = index + 1;
return itemPositioningBuilder(
context,
Text(
localizations.datePickerDayOfMonth(day),
style: _themeTextStyle(context, isValid: day <= daysInCurrentMonth),
),
);
}),
looping: true,
),
);
}
Widget _buildMonthPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) {
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollStartNotification) {
isMonthPickerScrolling = true;
} else if (notification is ScrollEndNotification) {
isMonthPickerScrolling = false;
_pickerDidStopScrolling();
}
return false;
},
child: CupertinoPicker(
scrollController: monthController,
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
onSelectedItemChanged: (int index) {
selectedMonth = index + 1;
if (_isCurrentDateValid)
widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay));
},
children: List<Widget>.generate(12, (int index) {
final int month = index + 1;
final bool isInvalidMonth = (widget?.minimumDate?.year == selectedYear && widget.minimumDate.month > month)
|| (widget?.maximumDate?.year == selectedYear && widget.maximumDate.month < month);
return itemPositioningBuilder(
context,
Text(
localizations.datePickerMonth(month),
style: _themeTextStyle(context, isValid: !isInvalidMonth),
),
);
}),
looping: true,
),
);
}
Widget _buildYearPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) {
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollStartNotification) {
isYearPickerScrolling = true;
} else if (notification is ScrollEndNotification) {
isYearPickerScrolling = false;
_pickerDidStopScrolling();
}
return false;
},
child: CupertinoPicker.builder(
scrollController: yearController,
itemExtent: _kItemExtent,
offAxisFraction: offAxisFraction,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
onSelectedItemChanged: (int index) {
selectedYear = index;
if (_isCurrentDateValid)
widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay));
},
itemBuilder: (BuildContext context, int year) {
if (year < widget.minimumYear)
return null;
if (widget.maximumYear != null && year > widget.maximumYear)
return null;
final bool isValidYear = (widget?.minimumDate == null || widget.minimumDate.year <= year)
&& (widget?.maximumDate == null || widget.maximumDate.year >= year);
return itemPositioningBuilder(
context,
Text(
localizations.datePickerYear(year),
style: _themeTextStyle(context, isValid: isValidYear),
),
);
},
),
);
}
bool get _isCurrentDateValid {
final DateTime selectedDate = DateTime(selectedYear, selectedMonth, selectedDay);
final bool minCheck = widget.minimumDate?.isAfter(selectedDate) ?? false;
final bool maxCheck = widget.maximumDate?.isBefore(selectedDate) ?? false;
return !minCheck && !maxCheck && selectedDate.day == selectedDay;
}
// One or more pickers have just stopped scrolling.
void _pickerDidStopScrolling() {
// Call setState to update the greyed out days/months/years, as the currently
// selected year/month may have changed.
setState(() { });
if (isScrolling) {
return;
}
// Whenever scrolling lands on an invalid entry, the picker
// automatically scrolls to a valid one.
final DateTime selectedDate = DateTime(selectedYear, selectedMonth, selectedDay);
final bool minCheck = widget.minimumDate?.isAfter(selectedDate) ?? false;
final bool maxCheck = widget.maximumDate?.isBefore(selectedDate) ?? false;
if (minCheck || maxCheck) {
// We have minCheck === !maxCheck.
final DateTime targetDate = minCheck ? widget.minimumDate : widget.maximumDate;
_scrollToDate(targetDate);
return;
}
// Some months have less days (e.g. February). Go to the last day of that month
// if the selectedDay exceeds the maximum.
if (selectedDate.day != selectedDay) {
final DateTime lastDay = _lastDayInMonth(selectedYear, selectedMonth);
_scrollToDate(lastDay);
}
}
void _scrollToDate(DateTime newDate) {
assert(newDate != null);
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
if (selectedYear != newDate.year) {
yearController.animateToItem(
newDate.year,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 200) ,
);
}
if (selectedMonth != newDate.month) {
monthController.animateToItem(
newDate.month - 1,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 200) ,
);
}
if (selectedDay != newDate.day) {
dayController.animateToItem(
newDate.day - 1,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 200) ,
);
}
});
}
@override
Widget build(BuildContext context) {
List<_ColumnBuilder> pickerBuilders = <_ColumnBuilder>[];
List<double> columnWidths = <double>[];
switch (localizations.datePickerDateOrder) {
case DatePickerDateOrder.mdy:
pickerBuilders = <_ColumnBuilder>[_buildMonthPicker, _buildDayPicker, _buildYearPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.month.index],
estimatedColumnWidths[_PickerColumnType.dayOfMonth.index],
estimatedColumnWidths[_PickerColumnType.year.index]];
break;
case DatePickerDateOrder.dmy:
pickerBuilders = <_ColumnBuilder>[_buildDayPicker, _buildMonthPicker, _buildYearPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.dayOfMonth.index],
estimatedColumnWidths[_PickerColumnType.month.index],
estimatedColumnWidths[_PickerColumnType.year.index]];
break;
case DatePickerDateOrder.ymd:
pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildMonthPicker, _buildDayPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.year.index],
estimatedColumnWidths[_PickerColumnType.month.index],
estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]];
break;
case DatePickerDateOrder.ydm:
pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildDayPicker, _buildMonthPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.year.index],
estimatedColumnWidths[_PickerColumnType.dayOfMonth.index],
estimatedColumnWidths[_PickerColumnType.month.index]];
break;
default:
assert(false, 'date order is not specified');
}
final List<Widget> pickers = <Widget>[];
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: DefaultTextStyle.merge(
style: _kDefaultPickerTextStyle,
child: CustomMultiChildLayout(
delegate: _DatePickerLayoutDelegate(
columnWidths: columnWidths,
textDirectionFactor: textDirectionFactor,
),
children: pickers,
),
),
);
}
}
// The iOS date picker and timer picker has their width fixed to 320.0 in all
// modes. The only exception is the hms mode (which doesn't have a native counterpart),
// with a fixed width of 330.0 px.
//
// For date pickers, if the maximum width given to the picker is greater than
// 320.0, the leftmost and rightmost column will be extended equally so that the
// widths match, and the picker is in the center.
//
// For timer pickers, if the maximum width given to the picker is greater than
// its intrinsic width, it will keep its intrinsic size and position itself in the
// parent using its alignment parameter.
//
// If the maximum width given to the picker is smaller than 320.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.
///
/// Examples: 16 hours | 14 min.
hm,
/// Mode that shows the timer duration in minute and second.
///
/// Examples: 14 min | 43 sec.
ms,
/// Mode that shows the timer duration in hour, minute, and second.
///
/// Examples: 16 hours | 14 min | 43 sec.
hms,
}
/// A countdown timer picker in iOS style.
///
/// This picker shows a countdown duration with hour, minute and second spinners.
/// The duration is bound between 0 and 23 hours 59 minutes 59 seconds.
///
/// There are several modes of the timer picker listed in [CupertinoTimerPickerMode].
///
/// The picker has a fixed size of 320 x 216, in logical pixels, with the exception
/// of [CupertinoTimerPickerMode.hms], which is 330 x 216. If the parent widget
/// provides more space than it needs, the picker will position itself according
/// to its [alignment] property.
///
/// 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 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.
///
/// [minuteInterval] is the granularity of the minute spinner. Must be a
/// positive integer factor of 60.
///
/// [secondInterval] is the granularity of the second spinner. Must be a
/// positive integer factor of 60.
CupertinoTimerPicker({
Key key,
this.mode = CupertinoTimerPickerMode.hms,
this.initialTimerDuration = Duration.zero,
this.minuteInterval = 1,
this.secondInterval = 1,
this.alignment = Alignment.center,
this.backgroundColor = _kBackgroundColor,
@required this.onTimerDurationChanged,
}) : assert(mode != null),
assert(onTimerDurationChanged != null),
assert(initialTimerDuration >= Duration.zero),
assert(initialTimerDuration < const Duration(days: 1)),
assert(minuteInterval > 0 && 60 % minuteInterval == 0),
assert(secondInterval > 0 && 60 % secondInterval == 0),
assert(initialTimerDuration.inMinutes % minuteInterval == 0),
assert(initialTimerDuration.inSeconds % secondInterval == 0),
assert(backgroundColor != null),
assert(alignment != null),
super(key: key);
/// The mode of the timer picker.
final CupertinoTimerPickerMode mode;
/// The initial duration of the countdown timer.
final Duration initialTimerDuration;
/// The granularity of the minute spinner. Must be a positive integer factor
/// of 60.
final int minuteInterval;
/// The granularity of the second spinner. Must be a positive integer factor
/// of 60.
final int secondInterval;
/// Callback called when the timer duration changes.
final ValueChanged<Duration> onTimerDurationChanged;
/// Defines how the timer picker should be positioned within its parent.
///
/// This property must not be null. It defaults to [Alignment.center].
final AlignmentGeometry alignment;
/// Background color of timer picker.
///
/// Defaults to [CupertinoColors.white] when null.
final Color backgroundColor;
@override
State<StatefulWidget> createState() => _CupertinoTimerPickerState();
}
class _CupertinoTimerPickerState extends State<CupertinoTimerPicker> {
TextDirection textDirection;
CupertinoLocalizations localizations;
int get textDirectionFactor {
switch (textDirection) {
case TextDirection.ltr:
return 1;
case TextDirection.rtl:
return -1;
}
return 1;
}
// The currently selected values of the picker.
int selectedHour;
int selectedMinute;
int selectedSecond;
// On iOS the selected values won't be reported until the scrolling fully stops.
// The values below are the latest selected values when the picker comes to a full stop.
int lastSelectedHour;
int lastSelectedMinute;
int lastSelectedSecond;
final TextPainter textPainter = TextPainter();
final List<String> numbers = List<String>.generate(10, (int i) => '${9 - i}');
double numberLabelWidth;
double numberLabelHeight;
double numberLabelBaseline;
@override
void initState() {
super.initState();
selectedMinute = widget.initialTimerDuration.inMinutes % 60;
if (widget.mode != CupertinoTimerPickerMode.ms)
selectedHour = widget.initialTimerDuration.inHours;
if (widget.mode != CupertinoTimerPickerMode.hm)
selectedSecond = widget.initialTimerDuration.inSeconds % 60;
PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange);
}
void _handleSystemFontsChange() {
setState(() {
// System fonts change might cause the text layout width to change.
textPainter.markNeedsLayout();
_measureLabelMetrics();
});
}
@override
void dispose() {
PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange);
super.dispose();
}
@override
void didUpdateWidget(CupertinoTimerPicker oldWidget) {
super.didUpdateWidget(oldWidget);
assert(
oldWidget.mode == widget.mode,
"The CupertinoTimerPicker's mode cannot change once it's built",
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
textDirection = Directionality.of(context);
localizations = CupertinoLocalizations.of(context);
_measureLabelMetrics();
}
void _measureLabelMetrics() {
textPainter.textDirection = textDirection;
final TextStyle textStyle = _textStyleFrom(context);
double maxWidth = double.negativeInfinity;
String widestNumber;
// Assumes that:
// - 2-digit numbers are always wider than 1-digit numbers.
// - There's at least one number in 1-9 that's wider than or equal to 0.
// - The widest 2-digit number is composed of 2 same 1-digit numbers
// that has the biggest width.
// - If two different 1-digit numbers are of the same width, their corresponding
// 2 digit numbers are of the same width.
for (String input in numbers) {
textPainter.text = TextSpan(
text: input,
style: textStyle,
);
textPainter.layout();
if (textPainter.maxIntrinsicWidth > maxWidth) {
maxWidth = textPainter.maxIntrinsicWidth;
widestNumber = input;
}
}
textPainter.text = TextSpan(
text: '$widestNumber$widestNumber',
style: textStyle,
);
textPainter.layout();
numberLabelWidth = textPainter.maxIntrinsicWidth;
numberLabelHeight = textPainter.height;
numberLabelBaseline = textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic);
}
// Builds a text label with scale factor 1.0 and font weight semi-bold.
// `pickerPadding ` is the additional padding the corresponding picker has to apply
// around the `Text`, in order to extend its separators towards the closest
// horizontal edge of the encompassing widget.
Widget _buildLabel(String text, EdgeInsetsDirectional pickerPadding) {
final EdgeInsetsDirectional padding = EdgeInsetsDirectional.only(
start: numberLabelWidth
+ _kTimerPickerLabelPadSize
+ pickerPadding.start,
);
return IgnorePointer(
child: Container(
alignment: AlignmentDirectional.centerStart.resolve(textDirection),
padding: padding.resolve(textDirection),
child: SizedBox(
height: numberLabelHeight,
child: Baseline(
baseline: numberLabelBaseline,
baselineType: TextBaseline.alphabetic,
child: Text(
text,
style: const TextStyle(
fontSize: _kTimerPickerLabelFontSize,
fontWeight: FontWeight.w600,
),
maxLines: 1,
softWrap: false,
),
),
),
),
);
}
// The picker has to be wider than its content, since the separators
// are part of the picker.
Widget _buildPickerNumberLabel(String text, EdgeInsetsDirectional padding) {
return Container(
width: _kTimerPickerColumnIntrinsicWidth + padding.horizontal,
padding: padding.resolve(textDirection),
alignment: AlignmentDirectional.centerStart.resolve(textDirection),
child: Container(
width: numberLabelWidth,
alignment: AlignmentDirectional.centerEnd.resolve(textDirection),
child: Text(text, softWrap: false, maxLines: 1, overflow: TextOverflow.visible),
),
);
}
Widget _buildHourPicker(EdgeInsetsDirectional additionalPadding) {
return CupertinoPicker(
scrollController: FixedExtentScrollController(initialItem: selectedHour),
offAxisFraction: -0.5 * textDirectionFactor,
itemExtent: _kItemExtent,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
onSelectedItemChanged: (int index) {
setState(() {
selectedHour = index;
widget.onTimerDurationChanged(
Duration(
hours: selectedHour,
minutes: selectedMinute,
seconds: selectedSecond ?? 0));
});
},
children: List<Widget>.generate(24, (int index) {
final String semanticsLabel = textDirectionFactor == 1
? localizations.timerPickerHour(index) + localizations.timerPickerHourLabel(index)
: localizations.timerPickerHourLabel(index) + localizations.timerPickerHour(index);
return Semantics(
label: semanticsLabel,
excludeSemantics: true,
child: _buildPickerNumberLabel(localizations.timerPickerHour(index), additionalPadding),
);
}),
);
}
Widget _buildHourColumn(EdgeInsetsDirectional additionalPadding) {
return Stack(
children: <Widget>[
NotificationListener<ScrollEndNotification>(
onNotification: (ScrollEndNotification notification) {
setState(() { lastSelectedHour = selectedHour; });
return false;
},
child: _buildHourPicker(additionalPadding),
),
_buildLabel(
localizations.timerPickerHourLabel(lastSelectedHour ?? selectedHour),
additionalPadding,
),
],
);
}
Widget _buildMinutePicker(EdgeInsetsDirectional additionalPadding) {
double offAxisFraction;
switch (widget.mode) {
case CupertinoTimerPickerMode.hm:
offAxisFraction = 0.5 * textDirectionFactor;
break;
case CupertinoTimerPickerMode.hms:
offAxisFraction = 0.0;
break;
case CupertinoTimerPickerMode.ms:
offAxisFraction = -0.5 * textDirectionFactor;
}
return CupertinoPicker(
scrollController: FixedExtentScrollController(
initialItem: selectedMinute ~/ widget.minuteInterval,
),
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
looping: true,
onSelectedItemChanged: (int index) {
setState(() {
selectedMinute = index * widget.minuteInterval;
widget.onTimerDurationChanged(
Duration(
hours: selectedHour ?? 0,
minutes: selectedMinute,
seconds: selectedSecond ?? 0));
});
},
children: List<Widget>.generate(60 ~/ widget.minuteInterval, (int index) {
final int minute = index * widget.minuteInterval;
final String semanticsLabel = textDirectionFactor == 1
? localizations.timerPickerMinute(minute) + localizations.timerPickerMinuteLabel(minute)
: localizations.timerPickerMinuteLabel(minute) + localizations.timerPickerMinute(minute);
return Semantics(
label: semanticsLabel,
excludeSemantics: true,
child: _buildPickerNumberLabel(localizations.timerPickerMinute(minute), additionalPadding),
);
}),
);
}
Widget _buildMinuteColumn(EdgeInsetsDirectional additionalPadding) {
return Stack(
children: <Widget>[
NotificationListener<ScrollEndNotification>(
onNotification: (ScrollEndNotification notification) {
setState(() { lastSelectedMinute = selectedMinute; });
return false;
},
child: _buildMinutePicker(additionalPadding),
),
_buildLabel(
localizations.timerPickerMinuteLabel(lastSelectedMinute ?? selectedMinute),
additionalPadding,
),
],
);
}
Widget _buildSecondPicker(EdgeInsetsDirectional additionalPadding) {
final double offAxisFraction = 0.5 * textDirectionFactor;
return CupertinoPicker(
scrollController: FixedExtentScrollController(
initialItem: selectedSecond ~/ widget.secondInterval,
),
offAxisFraction: offAxisFraction,
itemExtent: _kItemExtent,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
looping: true,
onSelectedItemChanged: (int index) {
setState(() {
selectedSecond = index * widget.secondInterval;
widget.onTimerDurationChanged(
Duration(
hours: selectedHour ?? 0,
minutes: selectedMinute,
seconds: selectedSecond));
});
},
children: List<Widget>.generate(60 ~/ widget.secondInterval, (int index) {
final int second = index * widget.secondInterval;
final String semanticsLabel = textDirectionFactor == 1
? localizations.timerPickerSecond(second) + localizations.timerPickerSecondLabel(second)
: localizations.timerPickerSecondLabel(second) + localizations.timerPickerSecond(second);
return Semantics(
label: semanticsLabel,
excludeSemantics: true,
child: _buildPickerNumberLabel(localizations.timerPickerSecond(second), additionalPadding),
);
}),
);
}
Widget _buildSecondColumn(EdgeInsetsDirectional additionalPadding) {
return Stack(
children: <Widget>[
NotificationListener<ScrollEndNotification>(
onNotification: (ScrollEndNotification notification) {
setState(() { lastSelectedSecond = selectedSecond; });
return false;
},
child: _buildSecondPicker(additionalPadding),
),
_buildLabel(
localizations.timerPickerSecondLabel(lastSelectedSecond ?? selectedSecond),
additionalPadding,
),
],
);
}
TextStyle _textStyleFrom(BuildContext context) {
return CupertinoTheme.of(context).textTheme
.pickerTextStyle.merge(
const TextStyle(
fontSize: _kTimerPickerNumberLabelFontSize,
),
);
}
@override
Widget build(BuildContext context) {
// The timer picker can be divided into columns corresponding to hour,
// minute, and second. Each column consists of a scrollable and a fixed
// label on top of it.
List<Widget> columns;
const double paddingValue = _kPickerWidth - 2 * _kTimerPickerColumnIntrinsicWidth - 2 * _kTimerPickerHalfColumnPadding;
// The default totalWidth for 2-column modes.
double totalWidth = _kPickerWidth;
assert(paddingValue >= 0);
switch (widget.mode) {
case CupertinoTimerPickerMode.hm:
// Pad the widget to make it as wide as `_kPickerWidth`.
columns = <Widget>[
_buildHourColumn(const EdgeInsetsDirectional.only(start: paddingValue / 2, end: _kTimerPickerHalfColumnPadding)),
_buildMinuteColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: paddingValue / 2)),
];
break;
case CupertinoTimerPickerMode.ms:
// Pad the widget to make it as wide as `_kPickerWidth`.
columns = <Widget>[
_buildMinuteColumn(const EdgeInsetsDirectional.only(start: paddingValue / 2, end: _kTimerPickerHalfColumnPadding)),
_buildSecondColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: paddingValue / 2)),
];
break;
case CupertinoTimerPickerMode.hms:
const double paddingValue = _kTimerPickerHalfColumnPadding * 2;
totalWidth = _kTimerPickerColumnIntrinsicWidth * 3 + 4 * _kTimerPickerHalfColumnPadding + paddingValue;
columns = <Widget>[
_buildHourColumn(const EdgeInsetsDirectional.only(start: paddingValue / 2, end: _kTimerPickerHalfColumnPadding)),
_buildMinuteColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: _kTimerPickerHalfColumnPadding)),
_buildSecondColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: paddingValue / 2)),
];
break;
}
final CupertinoThemeData themeData = CupertinoTheme.of(context);
return MediaQuery(
data: const MediaQueryData(
// The native iOS picker's text scaling is fixed, so we will also fix it
// as well in our picker.
textScaleFactor: 1.0,
),
child: CupertinoTheme(
data: themeData.copyWith(
textTheme: themeData.textTheme.copyWith(
pickerTextStyle: _textStyleFrom(context),
),
),
child: Align(
alignment: widget.alignment,
child: Container(
color: _kBackgroundColor,
width: totalWidth,
height: _kPickerHeight,
child: DefaultTextStyle(
style: _textStyleFrom(context),
child: Row(children: columns.map((Widget child) => Expanded(child: child)).toList(growable: false)),
),
),
),
),
);
}
}