// Copyright 2015 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:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'button.dart'; import 'button_bar.dart'; import 'colors.dart'; import 'dialog.dart'; import 'feedback.dart'; import 'flat_button.dart'; import 'material_localizations.dart'; import 'theme.dart'; import 'typography.dart'; const Duration _kDialAnimateDuration = const Duration(milliseconds: 200); const double _kTwoPi = 2 * math.PI; const int _kHoursPerDay = 24; const int _kHoursPerPeriod = 12; const int _kMinutesPerHour = 60; const Duration _kVibrateCommitDelay = const Duration(milliseconds: 100); /// Whether the [TimeOfDay] is before or after noon. enum DayPeriod { /// Ante meridiem (before noon). am, /// Post meridiem (after noon). pm, } /// A value representing a time during the day @immutable class TimeOfDay { /// Creates a time of day. /// /// The [hour] argument must be between 0 and 23, inclusive. The [minute] /// argument must be between 0 and 59, inclusive. const TimeOfDay({ @required this.hour, @required this.minute }); /// Creates a time of day based on the given time. /// /// The [hour] is set to the time's hour and the [minute] is set to the time's /// minute in the timezone of the given [DateTime]. TimeOfDay.fromDateTime(DateTime time) : hour = time.hour, minute = time.minute; /// Creates a time of day based on the current time. /// /// The [hour] is set to the current hour and the [minute] is set to the /// current minute in the local time zone. factory TimeOfDay.now() { return new TimeOfDay.fromDateTime(new DateTime.now()); } /// Returns a new TimeOfDay with the hour and/or minute replaced. TimeOfDay replacing({ int hour, int minute }) { assert(hour == null || (hour >= 0 && hour < _kHoursPerDay)); assert(minute == null || (minute >= 0 && minute < _kMinutesPerHour)); return new TimeOfDay(hour: hour ?? this.hour, minute: minute ?? this.minute); } /// The selected hour, in 24 hour time from 0..23. final int hour; /// The selected minute. final int minute; /// Whether this time of day is before or after noon. DayPeriod get period => hour < _kHoursPerPeriod ? DayPeriod.am : DayPeriod.pm; /// Which hour of the current period (e.g., am or pm) this time is. int get hourOfPeriod => hour - periodOffset; String _addLeadingZeroIfNeeded(int value) { if (value < 10) return '0$value'; return value.toString(); } /// A string representing the hour, in 24 hour time (e.g., '04' or '18'). String get hourLabel => _addLeadingZeroIfNeeded(hour); /// A string representing the minute (e.g., '07'). String get minuteLabel => _addLeadingZeroIfNeeded(minute); /// A string representing the hour of the current period (e.g., '4' or '6'). String get hourOfPeriodLabel { // TODO(ianh): Localize. final int hourOfPeriod = this.hourOfPeriod; if (hourOfPeriod == 0) return '12'; return hourOfPeriod.toString(); } /// A string representing the current period (e.g., 'a.m.'). String get periodLabel => period == DayPeriod.am ? 'a.m.' : 'p.m.'; // TODO(ianh): Localize. /// The hour at which the current period starts. int get periodOffset => period == DayPeriod.am ? 0 : _kHoursPerPeriod; @override bool operator ==(dynamic other) { if (other is! TimeOfDay) return false; final TimeOfDay typedOther = other; return typedOther.hour == hour && typedOther.minute == minute; } @override int get hashCode => hashValues(hour, minute); // TODO(ianh): Localize. @override String toString() => '$hourOfPeriodLabel:$minuteLabel $periodLabel'; } enum _TimePickerMode { hour, minute } const double _kTimePickerHeaderPortraitHeight = 96.0; const double _kTimePickerHeaderLandscapeWidth = 168.0; const double _kTimePickerWidthPortrait = 328.0; const double _kTimePickerWidthLandscape = 512.0; const double _kTimePickerHeightPortrait = 484.0; const double _kTimePickerHeightLandscape = 304.0; const double _kPeriodGap = 8.0; enum _TimePickerHeaderId { hour, colon, minute, period, // AM/PM picker } class _TimePickerHeaderLayout extends MultiChildLayoutDelegate { _TimePickerHeaderLayout(this.orientation); final Orientation orientation; @override void performLayout(Size size) { final BoxConstraints constraints = new BoxConstraints.loose(size); final Size hourSize = layoutChild(_TimePickerHeaderId.hour, constraints); final Size colonSize = layoutChild(_TimePickerHeaderId.colon, constraints); final Size minuteSize = layoutChild(_TimePickerHeaderId.minute, constraints); final Size periodSize = layoutChild(_TimePickerHeaderId.period, constraints); switch (orientation) { // 11:57--period // // The colon is centered horizontally, the entire layout is centered vertically. // The "--" is a _kPeriodGap horizontal gap. case Orientation.portrait: final double width = colonSize.width / 2.0 + minuteSize.width + _kPeriodGap + periodSize.width; final double right = math.max(0.0, size.width / 2.0 - width); double x = size.width - right - periodSize.width; positionChild(_TimePickerHeaderId.period, new Offset(x, (size.height - periodSize.height) / 2.0)); x -= minuteSize.width + _kPeriodGap; positionChild(_TimePickerHeaderId.minute, new Offset(x, (size.height - minuteSize.height) / 2.0)); x -= colonSize.width; positionChild(_TimePickerHeaderId.colon, new Offset(x, (size.height - colonSize.height) / 2.0)); x -= hourSize.width; positionChild(_TimePickerHeaderId.hour, new Offset(x, (size.height - hourSize.height) / 2.0)); break; // 11:57 // -- // period // // The colon is centered horizontally, the entire layout is centered vertically. // The "--" is a _kPeriodGap vertical gap. case Orientation.landscape: final double width = colonSize.width / 2.0 + minuteSize.width; final double offset = math.max(0.0, size.width / 2.0 - width); final double timeHeight = math.max(hourSize.height, colonSize.height); final double height = timeHeight + _kPeriodGap + periodSize.height; final double timeCenter = (size.height - height) / 2.0 + timeHeight / 2.0; double x = size.width - offset - minuteSize.width; positionChild(_TimePickerHeaderId.minute, new Offset(x, timeCenter - minuteSize.height / 2.0)); x -= colonSize.width; positionChild(_TimePickerHeaderId.colon, new Offset(x, timeCenter - colonSize.height / 2.0)); x -= hourSize.width; positionChild(_TimePickerHeaderId.hour, new Offset(x, timeCenter - hourSize.height / 2.0)); x = (size.width - periodSize.width) / 2.0; positionChild(_TimePickerHeaderId.period, new Offset(x, timeCenter + timeHeight / 2.0 + _kPeriodGap)); break; } } @override bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation; } // TODO(ianh): Localize! class _TimePickerHeader extends StatelessWidget { const _TimePickerHeader({ @required this.selectedTime, @required this.mode, @required this.orientation, @required this.onModeChanged, @required this.onChanged, }) : assert(selectedTime != null), assert(mode != null), assert(orientation != null); final TimeOfDay selectedTime; final _TimePickerMode mode; final Orientation orientation; final ValueChanged<_TimePickerMode> onModeChanged; final ValueChanged<TimeOfDay> onChanged; void _handleChangeMode(_TimePickerMode value) { if (value != mode) onModeChanged(value); } void _handleChangeDayPeriod() { final int newHour = (selectedTime.hour + _kHoursPerPeriod) % _kHoursPerDay; onChanged(selectedTime.replacing(hour: newHour)); } TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) { // These font sizes aren't listed in the spec explicitly. I worked them out // by measuring the text using a screen ruler and comparing them to the // screen shots of the time picker in the spec. assert(orientation != null); switch (orientation) { case Orientation.portrait: return headerTextTheme.display3.copyWith(fontSize: 60.0); case Orientation.landscape: return headerTextTheme.display2.copyWith(fontSize: 50.0); } return null; } @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); final TextTheme headerTextTheme = themeData.primaryTextTheme; final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme); Color activeColor; Color inactiveColor; switch(themeData.primaryColorBrightness) { case Brightness.light: activeColor = Colors.black87; inactiveColor = Colors.black54; break; case Brightness.dark: activeColor = Colors.white; inactiveColor = Colors.white70; break; } Color backgroundColor; switch (themeData.brightness) { case Brightness.light: backgroundColor = themeData.primaryColor; break; case Brightness.dark: backgroundColor = themeData.backgroundColor; break; } final TextStyle activeStyle = baseHeaderStyle.copyWith(color: activeColor); final TextStyle inactiveStyle = baseHeaderStyle.copyWith(color: inactiveColor); final TextStyle hourStyle = mode == _TimePickerMode.hour ? activeStyle : inactiveStyle; final TextStyle minuteStyle = mode == _TimePickerMode.minute ? activeStyle : inactiveStyle; final TextStyle amStyle = headerTextTheme.subhead.copyWith( color: selectedTime.period == DayPeriod.am ? activeColor: inactiveColor ); final TextStyle pmStyle = headerTextTheme.subhead.copyWith( color: selectedTime.period == DayPeriod.pm ? activeColor: inactiveColor ); final Widget dayPeriodPicker = new GestureDetector( onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context), behavior: HitTestBehavior.opaque, child: new Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ new Text('AM', style: amStyle), const SizedBox(width: 0.0, height: 4.0), // Vertical spacer new Text('PM', style: pmStyle), ] ) ); final Widget hour = new GestureDetector( onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.hour), context), child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle), ); final Widget minute = new GestureDetector( onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.minute), context), child: new Text(selectedTime.minuteLabel, style: minuteStyle), ); final Widget colon = new Text(':', style: inactiveStyle); EdgeInsets padding; double height; double width; assert(orientation != null); switch(orientation) { case Orientation.portrait: height = _kTimePickerHeaderPortraitHeight; padding = const EdgeInsets.symmetric(horizontal: 24.0); break; case Orientation.landscape: width = _kTimePickerHeaderLandscapeWidth; padding = const EdgeInsets.symmetric(horizontal: 16.0); break; } return new Container( width: width, height: height, padding: padding, color: backgroundColor, child: new CustomMultiChildLayout( delegate: new _TimePickerHeaderLayout(orientation), children: <Widget>[ new LayoutId(id: _TimePickerHeaderId.hour, child: hour), new LayoutId(id: _TimePickerHeaderId.colon, child: colon), new LayoutId(id: _TimePickerHeaderId.minute, child: minute), new LayoutId(id: _TimePickerHeaderId.period, child: dayPeriodPicker), ], ) ); } } List<TextPainter> _initPainters(TextTheme textTheme, List<String> labels) { final TextStyle style = textTheme.subhead; final List<TextPainter> painters = new List<TextPainter>(labels.length); for (int i = 0; i < painters.length; ++i) { final String label = labels[i]; // TODO(abarth): Handle textScaleFactor. // https://github.com/flutter/flutter/issues/5939 painters[i] = new TextPainter( text: new TextSpan(style: style, text: label), textDirection: TextDirection.ltr, )..layout(); } return painters; } List<TextPainter> _initHours(TextTheme textTheme) { return _initPainters(textTheme, <String>[ '12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11' ]); } List<TextPainter> _initMinutes(TextTheme textTheme) { return _initPainters(textTheme, <String>[ '00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55' ]); } class _DialPainter extends CustomPainter { const _DialPainter({ this.primaryLabels, this.secondaryLabels, this.backgroundColor, this.accentColor, this.theta }); final List<TextPainter> primaryLabels; final List<TextPainter> secondaryLabels; final Color backgroundColor; final Color accentColor; final double theta; @override void paint(Canvas canvas, Size size) { final double radius = size.shortestSide / 2.0; final Offset center = new Offset(size.width / 2.0, size.height / 2.0); final Offset centerPoint = center; canvas.drawCircle(centerPoint, radius, new Paint()..color = backgroundColor); const double labelPadding = 24.0; final double labelRadius = radius - labelPadding; Offset getOffsetForTheta(double theta) { return center + new Offset(labelRadius * math.cos(theta), -labelRadius * math.sin(theta)); } void paintLabels(List<TextPainter> labels) { final double labelThetaIncrement = -_kTwoPi / labels.length; double labelTheta = math.PI / 2.0; for (TextPainter label in labels) { final Offset labelOffset = new Offset(-label.width / 2.0, -label.height / 2.0); label.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset); labelTheta += labelThetaIncrement; } } paintLabels(primaryLabels); final Paint selectorPaint = new Paint() ..color = accentColor; final Offset focusedPoint = getOffsetForTheta(theta); final double focusedRadius = labelPadding - 4.0; canvas.drawCircle(centerPoint, 4.0, selectorPaint); canvas.drawCircle(focusedPoint, focusedRadius, selectorPaint); selectorPaint.strokeWidth = 2.0; canvas.drawLine(centerPoint, focusedPoint, selectorPaint); final Rect focusedRect = new Rect.fromCircle( center: focusedPoint, radius: focusedRadius ); canvas ..save() ..clipPath(new Path()..addOval(focusedRect)); paintLabels(secondaryLabels); canvas.restore(); } @override bool shouldRepaint(_DialPainter oldPainter) { return oldPainter.primaryLabels != primaryLabels || oldPainter.secondaryLabels != secondaryLabels || oldPainter.backgroundColor != backgroundColor || oldPainter.accentColor != accentColor || oldPainter.theta != theta; } } class _Dial extends StatefulWidget { const _Dial({ @required this.selectedTime, @required this.mode, @required this.onChanged }) : assert(selectedTime != null); final TimeOfDay selectedTime; final _TimePickerMode mode; final ValueChanged<TimeOfDay> onChanged; @override _DialState createState() => new _DialState(); } class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override void initState() { super.initState(); _thetaController = new AnimationController( duration: _kDialAnimateDuration, vsync: this, ); _thetaTween = new Tween<double>(begin: _getThetaForTime(widget.selectedTime)); _theta = _thetaTween.animate(new CurvedAnimation( parent: _thetaController, curve: Curves.fastOutSlowIn ))..addListener(() => setState(() { })); } @override void didUpdateWidget(_Dial oldWidget) { super.didUpdateWidget(oldWidget); if (widget.mode != oldWidget.mode && !_dragging) _animateTo(_getThetaForTime(widget.selectedTime)); } @override void dispose() { _thetaController.dispose(); super.dispose(); } Tween<double> _thetaTween; Animation<double> _theta; AnimationController _thetaController; bool _dragging = false; static double _nearest(double target, double a, double b) { return ((target - a).abs() < (target - b).abs()) ? a : b; } void _animateTo(double targetTheta) { final double currentTheta = _theta.value; double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); _thetaTween ..begin = beginTheta ..end = targetTheta; _thetaController ..value = 0.0 ..forward(); } double _getThetaForTime(TimeOfDay time) { final double fraction = (widget.mode == _TimePickerMode.hour) ? (time.hour / _kHoursPerPeriod) % _kHoursPerPeriod : (time.minute / _kMinutesPerHour) % _kMinutesPerHour; return (math.PI / 2.0 - fraction * _kTwoPi) % _kTwoPi; } TimeOfDay _getTimeForTheta(double theta) { final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; if (widget.mode == _TimePickerMode.hour) { final int hourOfPeriod = (fraction * _kHoursPerPeriod).round() % _kHoursPerPeriod; return widget.selectedTime.replacing( hour: hourOfPeriod + widget.selectedTime.periodOffset ); } else { return widget.selectedTime.replacing( minute: (fraction * _kMinutesPerHour).round() % _kMinutesPerHour ); } } void _notifyOnChangedIfNeeded() { if (widget.onChanged == null) return; final TimeOfDay current = _getTimeForTheta(_theta.value); if (current != widget.selectedTime) widget.onChanged(current); } void _updateThetaForPan() { setState(() { final Offset offset = _position - _center; final double angle = (math.atan2(offset.dx, offset.dy) - math.PI / 2.0) % _kTwoPi; _thetaTween ..begin = angle ..end = angle; // The controller doesn't animate during the pan gesture. }); } Offset _position; Offset _center; void _handlePanStart(DragStartDetails details) { assert(!_dragging); _dragging = true; final RenderBox box = context.findRenderObject(); _position = box.globalToLocal(details.globalPosition); _center = box.size.center(Offset.zero); _updateThetaForPan(); _notifyOnChangedIfNeeded(); } void _handlePanUpdate(DragUpdateDetails details) { _position += details.delta; _updateThetaForPan(); _notifyOnChangedIfNeeded(); } void _handlePanEnd(DragEndDetails details) { assert(_dragging); _dragging = false; _position = null; _center = null; _animateTo(_getThetaForTime(widget.selectedTime)); } @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); Color backgroundColor; switch (themeData.brightness) { case Brightness.light: backgroundColor = Colors.grey[200]; break; case Brightness.dark: backgroundColor = themeData.backgroundColor; break; } final ThemeData theme = Theme.of(context); List<TextPainter> primaryLabels; List<TextPainter> secondaryLabels; switch (widget.mode) { case _TimePickerMode.hour: primaryLabels = _initHours(theme.textTheme); secondaryLabels = _initHours(theme.accentTextTheme); break; case _TimePickerMode.minute: primaryLabels = _initMinutes(theme.textTheme); secondaryLabels = _initMinutes(theme.accentTextTheme); break; } return new GestureDetector( onPanStart: _handlePanStart, onPanUpdate: _handlePanUpdate, onPanEnd: _handlePanEnd, child: new CustomPaint( key: const ValueKey<String>('time-picker-dial'), // used for testing. painter: new _DialPainter( primaryLabels: primaryLabels, secondaryLabels: secondaryLabels, backgroundColor: backgroundColor, accentColor: themeData.accentColor, theta: _theta.value ) ) ); } } class _TimePickerDialog extends StatefulWidget { const _TimePickerDialog({ Key key, @required this.initialTime }) : assert(initialTime != null), super(key: key); final TimeOfDay initialTime; @override _TimePickerDialogState createState() => new _TimePickerDialogState(); } class _TimePickerDialogState extends State<_TimePickerDialog> { @override void initState() { super.initState(); _selectedTime = widget.initialTime; } _TimePickerMode _mode = _TimePickerMode.hour; TimeOfDay _selectedTime; Timer _vibrateTimer; void _vibrate() { switch (Theme.of(context).platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: _vibrateTimer?.cancel(); _vibrateTimer = new Timer(_kVibrateCommitDelay, () { HapticFeedback.vibrate(); _vibrateTimer = null; }); break; case TargetPlatform.iOS: break; } } void _handleModeChanged(_TimePickerMode mode) { _vibrate(); setState(() { _mode = mode; }); } void _handleTimeChanged(TimeOfDay value) { _vibrate(); setState(() { _selectedTime = value; }); } void _handleCancel() { Navigator.pop(context); } void _handleOk() { Navigator.pop(context, _selectedTime); } @override Widget build(BuildContext context) { final Widget picker = new Padding( padding: const EdgeInsets.all(16.0), child: new AspectRatio( aspectRatio: 1.0, child: new _Dial( mode: _mode, selectedTime: _selectedTime, onChanged: _handleTimeChanged, ) ) ); final MaterialLocalizations localizations = MaterialLocalizations.of(context); final Widget actions = new ButtonTheme.bar( child: new ButtonBar( children: <Widget>[ new FlatButton( child: new Text(localizations.cancelButtonLabel), onPressed: _handleCancel ), new FlatButton( child: new Text(localizations.okButtonLabel), onPressed: _handleOk ), ] ) ); return new Dialog( child: new OrientationBuilder( builder: (BuildContext context, Orientation orientation) { final Widget header = new _TimePickerHeader( selectedTime: _selectedTime, mode: _mode, orientation: orientation, onModeChanged: _handleModeChanged, onChanged: _handleTimeChanged, ); assert(orientation != null); switch (orientation) { case Orientation.portrait: return new SizedBox( width: _kTimePickerWidthPortrait, height: _kTimePickerHeightPortrait, child: new Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[ header, new Expanded(child: picker), actions, ] ) ); case Orientation.landscape: return new SizedBox( width: _kTimePickerWidthLandscape, height: _kTimePickerHeightLandscape, child: new Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[ header, new Flexible( child: new Column( children: <Widget>[ new Expanded(child: picker), actions, ] ) ), ] ) ); } return null; } ) ); } @override void dispose() { _vibrateTimer?.cancel(); _vibrateTimer = null; super.dispose(); } } /// Shows a dialog containing a material design time picker. /// /// The returned Future resolves to the time selected by the user when the user /// closes the dialog. If the user cancels the dialog, null is returned. /// /// To show a dialog with [initialTime] equal to the current time: /// ```dart /// showTimePicker( /// initialTime: new TimeOfDay.now(), /// context: context /// ); /// ``` /// /// See also: /// /// * [showDatePicker] /// * <https://material.google.com/components/pickers.html#pickers-time-pickers> Future<TimeOfDay> showTimePicker({ @required BuildContext context, @required TimeOfDay initialTime }) async { assert(context != null); assert(initialTime != null); return await showDialog( context: context, child: new _TimePickerDialog(initialTime: initialTime) ); }